Skip to content
CalliCoder

JPA One to One Mapping Example with Spring Boot

Spring Boot 13 min read

Why the foreign key ends up on the side you did not expect, why a lazy one-to-one is eager anyway on the inverse side, and what @MapsId changes about the schema.

One-to-one looks like the simplest JPA relationship and contains the least obvious surprise: on the inverse side, FetchType.LAZY does not work. Hibernate loads the association eagerly regardless of what you declared, and the extra query per row is invisible until a list page gets slow.

That, plus the choice of where the foreign key lives, is most of what there is to know.

Written against Spring Boot 3.2, Hibernate 6.4 and Java 17.

The default: a foreign key on the owning side

@Entity
@Table(name = "users")
public class User {

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

    private String email;

    @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY,
              optional = false, orphanRemoval = true)
    private Address address;
}
@Entity
@Table(name = "addresses")
public class Address {

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

    private String street;
    private String city;

    @OneToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "user_id", nullable = false, unique = true)
    private User user;
}

Address owns the relationship: it declares @JoinColumn, so addresses.user_id is the foreign key. User declares mappedBy = "user" and holds no column at all.

Add unique = true yourself. Without it JPA generates a plain foreign key and nothing stops two addresses pointing at one user, the database will happily store a one-to-many that your mapping insists is a one-to-one, and the error surfaces later as NonUniqueResultException from a query that should be safe.

Both sides can be optional = false, and that is the honest declaration when the address is mandatory. It also lets Hibernate use an inner join rather than a left join.

Cascade and orphanRemoval belong here

Unlike a many-to-many, CascadeType.ALL is usually correct on a one-to-one, because the dependent side genuinely has no life of its own: an address exists for exactly one user and should be deleted with them. orphanRemoval = true extends that to reassignment: setting user.setAddress(newOne) deletes the old row rather than leaving it orphaned with a dangling foreign key.

Keep both sides in step with a helper, the same as any bidirectional association:

public void setAddress(Address address) {
    if (address == null) {
        if (this.address != null) this.address.setUser(null);
    } else {
        address.setUser(this);
    }
    this.address = address;
}

The lazy problem on the inverse side

fetch = FetchType.LAZY on Address.user works: Hibernate substitutes a proxy and loads the user on first access. fetch = FetchType.LAZY on User.address does not, and cannot.

The reason is mechanical. To return a proxy, Hibernate must know whether the row exists, a proxy for a missing row would make user.getAddress() return a non-null object for an absent association. The owning side knows from its own foreign key column, which is either null or not. The inverse side holds no column, so answering “does this user have an address” requires a query. Having run it, Hibernate has the row and returns the real entity.

The consequence: loading 100 users issues 101 queries even though the mapping says lazy. Three ways out, in order of preference:

Fetch join when you need it.

@Query("SELECT u FROM User u LEFT JOIN FETCH u.address WHERE u.active = true")
List<User> findActiveWithAddress();

Or an entity graph, which composes with derived queries:

@EntityGraph(attributePaths = "address")
List<User> findByActiveTrue();

Or @MapsId, which removes the problem entirely by making the association the primary key.

@MapsId: a shared primary key

@Entity
@Table(name = "addresses")
public class Address {

    @Id
    private Long id;          // no @GeneratedValue — it comes from the user

    @OneToOne(fetch = FetchType.LAZY, optional = false)
    @MapsId
    @JoinColumn(name = "user_id")
    private User user;

    private String street;
    private String city;
}

Now addresses.user_id is both the primary key and the foreign key. The schema is one column narrower, the uniqueness is enforced by the primary key rather than an added constraint, and there is no separate surrogate id to carry around.

It also changes the inverse side for the better. user.getAddress() still cannot be a proxy, but the address is now reachable by primary key, so it is a first-level cache hit whenever the address was already loaded, and addressRepository.findById(userId) becomes a legitimate way to fetch it without touching User at all.

Persisting is slightly different: the user must be set before the address is saved, since the address has no id of its own until then.

Address address = new Address();
address.setUser(user);        // supplies the identifier
address.setCity("Berlin");
addressRepository.save(address);

Saving an Address with a null user under @MapsId fails with an identifier-generation error rather than a constraint violation, which is a confusing message for a simple omission.

Verifying the mapping

Read the generated schema rather than the annotations:

SHOW CREATE TABLE addresses;

Three things to look for. A user_id column that is NOT NULL when the association is optional = false. A UNIQUE index on it. Its absence is the silent defect described above. And, under @MapsId, no separate id column at all: user_id should be the primary key.

Then count queries. With the SQL log on:

logging.level.org.hibernate.SQL=DEBUG

loading ten users and touching getAddress() on each should produce one query if a fetch join or entity graph is in play, and eleven if it is not. Eleven is the inverse-side eagerness described above, not a mistake in the mapping. It is the reason to reach for @MapsId or a fetch join in the first place.

A test that asserts the statement count is more durable than reading the log by eye. Hibernate’s SessionFactory exposes getStatistics().getPrepareStatementCount() once spring.jpa.properties.hibernate.generate_statistics=true is set, and asserting on it turns an N+1 regression into a failing build rather than a slow page nobody attributes to this mapping.

Which side should own it?

Put the foreign key on the side that is optional, or on the more numerous table if one is sparsely populated. A users table with a nullable address_id and a users/addresses pair where the address holds user_id describe the same relationship and behave differently under querying: the side holding the column can filter on it without a join.

If the answer is “they are always both present”, @MapsId is the mapping that says so.

There is a third option worth ruling out first. If the child has no identity of its own (no id you would ever expose, no rows shared with anything else), it may not need to be an entity at all. An @Embeddable maps the same fields into the parent’s own table and removes the join, the cascade and the lazy-loading question in one move; the @Embeddable and @Embedded example covers when that trade is the right one. Reach for a one-to-one when the child needs its own table: it is queried independently. It is large enough to be worth loading separately, or another entity references it.

Serialisation

A bidirectional one-to-one serialises into infinite recursion, user contains address contains user. The direct fix is @JsonManagedReference on the parent and @JsonBackReference on the child, or @JsonIgnore on the inverse property. The better fix on an API is not to serialise entities at all; map to a response record in the service layer, which is what the REST API walkthrough does.

Related mappings: one-to-many and many-to-many. More in the Spring Boot guides.

Frequently asked questions

Which entity owns a one-to-one relationship?

The one with @JoinColumn. It holds the foreign key. The other side declares mappedBy and stores nothing.

Why is my lazy one-to-one still eager?

On the inverse side it always is. Hibernate cannot return a proxy without knowing whether the row exists, and finding out requires the query it was trying to avoid. Only the owning side can be genuinely lazy.

How do I stop the N+1 on the inverse side?

LEFT JOIN FETCH, @EntityGraph(attributePaths = ...), or restructure with @MapsId so the child is reachable by primary key.

Do I need unique = true on the join column?

Yes. JPA generates an ordinary foreign key otherwise, and the database will allow two children to reference the same parent, a one-to-many the mapping does not expect.

What does @MapsId actually do?

It makes the child’s primary key the same column as its foreign key to the parent. One column instead of two, uniqueness enforced by the primary key, and no separate generated id.

Is CascadeType.ALL safe on a one-to-one?

Usually yes, unlike a many-to-many. The dependent side exists only for its parent, so cascading persist, merge and remove matches the real lifecycle. Add orphanRemoval = true so reassignment deletes the old row.

Why does saving fail with @MapsId?

The parent was not set before the save. Under @MapsId the child has no identifier of its own, the parent supplies it, so the association must be assigned first.

Should I use optional = false?

When the association is mandatory, yes. It lets Hibernate use an inner join instead of a left join, and it documents the constraint in the mapping rather than only in the schema.

How do I avoid infinite recursion in JSON?

@JsonManagedReference / @JsonBackReference, or @JsonIgnore on one side. Better still, do not serialise entities, return a response record built in the service layer.

Can one-to-one be unidirectional?

Yes, and it is simpler. Keep @OneToOne with @JoinColumn on the side that needs the reference and omit the property on the other entity entirely. You lose navigation in one direction and every problem above with it.