Spring BootPrimary Keys
Spring Boot

Primary Keys

Primary keys uniquely identify every row in a table. JPA maps them with @Id and controls generation with @GeneratedValue. Spring Boot with Hibernate supports four generation strategies — IDENTITY, SEQUENCE, TABLE, and AUTO — plus application-assigned keys, UUID keys, and composite keys via @IdClass or @EmbeddedId. This entry covers all strategies, their trade-offs, batch insert implications, and composite key patterns.

IDENTITY Strategy

IDENTITY delegates key generation to a database auto-increment column — SERIAL or BIGSERIAL in PostgreSQL, AUTO_INCREMENT in MySQL. It is the simplest strategy and the most common choice for single-instance applications. Its main limitation is that Hibernate must execute the INSERT immediately to obtain the generated key, which prevents JDBC batch inserts from being used.
Java
// ── Basic IDENTITY key ────────────────────────────────────────────────
@Entity
@Table(name = "users")
@Getter @Setter @NoArgsConstructor
public class User {

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

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

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

// ── PostgreSQL DDL generated by Hibernate ─────────────────────────────
// CREATE TABLE users (
//     id     BIGSERIAL    PRIMARY KEY,
//     name   VARCHAR(100) NOT NULL,
//     email  VARCHAR(255) NOT NULL UNIQUE
// );

// ── MySQL DDL generated by Hibernate ──────────────────────────────────
// CREATE TABLE users (
//     id     BIGINT       NOT NULL AUTO_INCREMENT PRIMARY KEY,
//     name   VARCHAR(100) NOT NULL,
//     email  VARCHAR(255) NOT NULL UNIQUE
// );

// ── Why IDENTITY blocks batch inserts ────────────────────────────────
// Hibernate needs the generated ID to build the managed entity.
// With IDENTITY it must flush each INSERT individually to get the key,
// so even with spring.jpa.properties.hibernate.jdbc.batch_size=50
// inserts are sent one at a time.
//
// If batch inserts matter, use SEQUENCE instead.

SEQUENCE Strategy

SEQUENCE uses a database sequence object to generate keys before the INSERT is executed. Hibernate fetches a block of IDs from the sequence in a single round-trip (controlled by allocationSize), assigns them in memory, and batches the INSERTs. This makes SEQUENCE the preferred strategy for PostgreSQL when throughput matters.
Java
// ── Basic sequence ────────────────────────────────────────────────────
@Entity
@Table(name = "orders")
@SequenceGenerator(
    name           = "order_seq",           // referenced by @GeneratedValue
    sequenceName   = "order_id_seq",        // actual sequence name in DB
    allocationSize = 50                     // fetch 50 IDs per DB round-trip
)
@Getter @Setter @NoArgsConstructor
public class Order {

    @Id
    @GeneratedValue(
        strategy  = GenerationType.SEQUENCE,
        generator = "order_seq"
    )
    private Long id;

    @Column(name = "customer_id", nullable = false)
    private Long customerId;

    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal total;
}

// ── PostgreSQL DDL ─────────────────────────────────────────────────────
// CREATE SEQUENCE order_id_seq START 1 INCREMENT 50;
// CREATE TABLE orders (
//     id          BIGINT         PRIMARY KEY,
//     customer_id BIGINT         NOT NULL,
//     total       NUMERIC(10, 2) NOT NULL
// );

// ── Shared sequence across entities ───────────────────────────────────
// Some schemas use one sequence for all tables (hi-lo pattern):
@SequenceGenerator(
    name           = "global_seq",
    sequenceName   = "global_id_seq",
    allocationSize = 100
)

// ── allocationSize trade-off ──────────────────────────────────────────
// Small (1)   → one DB call per INSERT — safe but slow for bulk loads
// Large (50+) → fewer DB calls — IDs jump by allocationSize on restart,
//               leaving gaps, but gaps are harmless in practice
//
// The sequence INCREMENT BY in the DB must match allocationSize:
// CREATE SEQUENCE order_id_seq START 1 INCREMENT 50;

// ── Enable JDBC batching alongside SEQUENCE ───────────────────────────
// application.yml:
// spring:
//   jpa:
//     properties:
//       hibernate:
//         jdbc:
//           batch_size: 50
//         order_inserts: true
//         order_updates: true

AUTO and TABLE Strategies

AUTO lets Hibernate choose the strategy based on the database dialect — typically SEQUENCE for PostgreSQL and IDENTITY for MySQL. TABLE uses a dedicated key table and is portable across all databases but serialises key generation through row-level locking, making it a bottleneck under concurrency. Avoid TABLE in new projects.
Java
// ── AUTO — Hibernate picks the strategy per dialect ──────────────────
@Entity
@Table(name = "events")
@Getter @Setter @NoArgsConstructor
public class Event {

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

    // PostgreSQL dialect → uses a sequence: hibernate_sequence
    // MySQL dialect      → uses AUTO_INCREMENT
    // H2 dialect         → uses a sequence
}

// ── AUTO with Hibernate 6 default sequence ────────────────────────────
// Hibernate 6 uses a single shared sequence by default with AUTO:
//   CREATE SEQUENCE hibernate_sequence START 1 INCREMENT 1;
// To use a dedicated sequence, switch to SEQUENCE explicitly.

// ── TABLE — portable but avoid in production ──────────────────────────
@Entity
@Table(name = "legacy_records")
@TableGenerator(
    name             = "legacy_gen",
    table            = "id_generator",      // key table name
    pkColumnName     = "gen_name",          // column holding the sequence name
    valueColumnName  = "gen_value",         // column holding the next value
    pkColumnValue    = "legacy_record_id",  // row in the key table
    allocationSize   = 1
)
@Getter @Setter @NoArgsConstructor
public class LegacyRecord {

    @Id
    @GeneratedValue(
        strategy  = GenerationType.TABLE,
        generator = "legacy_gen"
    )
    private Long id;
}

// ── id_generator table structure ──────────────────────────────────────
// CREATE TABLE id_generator (
//     gen_name  VARCHAR(255) PRIMARY KEY,
//     gen_value BIGINT       NOT NULL
// );
//
// Row-level locking on id_generator serialises key generation —
// every INSERT waits for the lock. Under any real concurrency load
// this becomes a bottleneck. Use SEQUENCE or IDENTITY instead.

UUID Primary Keys

UUID primary keys are globally unique without coordination, making them safe for distributed systems, client-side ID generation, and data merges across databases. The trade-off is a larger storage footprint (16 bytes vs 8 bytes for a Long) and random insertion order, which fragments B-tree indexes. Use UUID version 7 (time-ordered) to mitigate fragmentation when the database supports it.
Java
// ── UUID generated by Hibernate (JPA 3.1 / Hibernate 6+) ────────────
@Entity
@Table(name = "documents")
@Getter @Setter @NoArgsConstructor
public class Document {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    @Column(
        name       = "id",
        updatable  = false,
        nullable   = false,
        columnDefinition = "uuid"           // PostgreSQL native uuid type
    )
    private UUID id;

    @Column(nullable = false, length = 300)
    private String title;
}

// ── Application-assigned UUID (pre-Hibernate 6 or for UUID v7) ────────
@Entity
@Table(name = "events")
@Getter @Setter @NoArgsConstructor
public class Event {

    @Id
    @Column(updatable = false, nullable = false, columnDefinition = "uuid")
    private UUID id;

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

    // Assign in the service or a @PrePersist callback:
    @PrePersist
    protected void onCreate() {
        if (id == null) {
            id = UUID.randomUUID();         // v4 — random
            // id = UuidCreator.getTimeOrderedEpoch(); // v7 — time-ordered
        }
    }
}

// ── UUID stored as VARCHAR for databases without a native UUID type ───
@Entity
@Table(name = "notifications")
@Getter @Setter @NoArgsConstructor
public class Notification {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    @Column(name = "id", updatable = false, nullable = false, length = 36)
    private String id;          // stored as "550e8400-e29b-41d4-a716-446655440000"
}

// ── UUID v7 dependency (com.github.f4b6a3:uuid-creator) ───────────────
// <dependency>
//     <groupId>com.github.f4b6a3</groupId>
//     <artifactId>uuid-creator</artifactId>
//     <version>5.3.7</version>
// </dependency>
//
// UUID v7 embeds a millisecond timestamp in the first 48 bits,
// giving monotonically increasing values that insert in order
// and avoid B-tree fragmentation.

Assigned Keys

When the application controls the primary key value — natural keys such as ISO country codes, IATA airport codes, or business identifiers — omit @GeneratedValue entirely. The caller is responsible for setting the ID before persisting. Hibernate uses the presence of the ID field to decide whether to INSERT or UPDATE, so assigned keys require using the Persistable interface or checking existence explicitly.
Java
// ── Simple assigned key ───────────────────────────────────────────────
@Entity
@Table(name = "countries")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor
public class Country {

    @Id
    @Column(name = "iso_code", length = 2, nullable = false)
    private String isoCode;             // "US", "GB", "DE" — set by caller

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

    @Column(name = "dial_code", length = 5)
    private String dialCode;            // "+1", "+44", "+49"
}

// ── Persistable — tells Hibernate when the entity is new ─────────────
// Without Persistable, Hibernate issues a SELECT before every save()
// to decide INSERT vs UPDATE. Implementing Persistable avoids the
// extra round-trip for assigned-key entities.
@Entity
@Table(name = "currencies")
@Getter @Setter @NoArgsConstructor
public class Currency implements Persistable<String> {

    @Id
    @Column(name = "code", length = 3, nullable = false)
    private String code;                // "USD", "EUR", "GBP"

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

    @Transient                          // not persisted — marks new entities
    private boolean isNew = true;

    @PostLoad                           // after load from DB — no longer new
    @PostPersist                        // after first persist — no longer new
    void markNotNew() { this.isNew = false; }

    @Override public String getId()    { return code; }
    @Override public boolean isNew()   { return isNew; }
}

// ── Service usage ─────────────────────────────────────────────────────
@Service
@RequiredArgsConstructor
public class CountryService {

    private final CountryRepository countryRepo;

    public Country create(String isoCode, String name, String dialCode) {
        if (countryRepo.existsById(isoCode)) {
            throw new DuplicateResourceException(
                "Country already exists: " + isoCode);
        }
        return countryRepo.save(
            new Country(isoCode, name, dialCode));
    }
}

Composite Keys with @IdClass

@IdClass declares a separate ID class whose fields mirror the @Id fields on the entity. The ID class must implement Serializable, override equals and hashCode, and have a no-arg constructor. It is the simpler of the two composite key approaches and works well when the entity fields need to be accessed directly without going through an embedded object.
Java
// ── ID class ──────────────────────────────────────────────────────────
@Data
@NoArgsConstructor
@AllArgsConstructor
public class OrderItemId implements Serializable {

    private Long orderId;
    private Long productId;

    // @Data generates equals() and hashCode() — required by JPA spec
}

// ── Entity with @IdClass ───────────────────────────────────────────────
@Entity
@Table(
    name = "order_items",
    indexes = {
        @Index(name = "idx_order_items_order_id",
               columnList = "order_id"),
        @Index(name = "idx_order_items_product_id",
               columnList = "product_id")
    }
)
@IdClass(OrderItemId.class)
@Getter @Setter @NoArgsConstructor @AllArgsConstructor
public class OrderItem {

    @Id
    @Column(name = "order_id", nullable = false)
    private Long orderId;

    @Id
    @Column(name = "product_id", nullable = false)
    private Long productId;

    @Column(nullable = false)
    @Min(1)
    private int quantity;

    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal unitPrice;

    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal lineTotal;
}

// ── Repository ────────────────────────────────────────────────────────
public interface OrderItemRepository
        extends JpaRepository<OrderItem, OrderItemId> {

    List<OrderItem> findByOrderId(Long orderId);
    List<OrderItem> findByProductId(Long productId);
}

// ── Service usage ─────────────────────────────────────────────────────
@Service
@RequiredArgsConstructor
public class OrderItemService {

    private final OrderItemRepository repo;

    public OrderItem findById(Long orderId, Long productId) {
        return repo.findById(new OrderItemId(orderId, productId))
            .orElseThrow(() -> new ResourceNotFoundException(
                "OrderItem not found for order " + orderId
                + " and product " + productId));
    }
}

Composite Keys with @EmbeddedId

@EmbeddedId uses a @Embeddable class as the primary key. The key is a first-class object — repositories and queries reference it as a whole. Use @EmbeddedId when you want to pass the composite key around as a value object or when JPQL queries benefit from addressing the key as a single embedded property.
Java
// ── Embeddable key class ──────────────────────────────────────────────
@Embeddable
@Data
@NoArgsConstructor
@AllArgsConstructor
public class EnrollmentId implements Serializable {

    @Column(name = "student_id", nullable = false)
    private Long studentId;

    @Column(name = "course_id", nullable = false)
    private Long courseId;
}

// ── Entity with @EmbeddedId ───────────────────────────────────────────
@Entity
@Table(
    name = "enrollments",
    indexes = {
        @Index(name = "idx_enrollments_student",
               columnList = "student_id"),
        @Index(name = "idx_enrollments_course",
               columnList = "course_id")
    }
)
@Getter @Setter @NoArgsConstructor
public class Enrollment {

    @EmbeddedId
    private EnrollmentId id;

    @ManyToOne(fetch = FetchType.LAZY)
    @MapsId("studentId")                    // maps to EnrollmentId.studentId
    @JoinColumn(name = "student_id",
                foreignKey = @ForeignKey(name = "fk_enrollments_student"))
    private Student student;

    @ManyToOne(fetch = FetchType.LAZY)
    @MapsId("courseId")                     // maps to EnrollmentId.courseId
    @JoinColumn(name = "course_id",
                foreignKey = @ForeignKey(name = "fk_enrollments_course"))
    private Course course;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private EnrollmentStatus status;

    @Column(name = "enrolled_at", nullable = false, updatable = false)
    private LocalDateTime enrolledAt;

    @Column(name = "completed_at")
    private LocalDateTime completedAt;
}

// ── Repository ────────────────────────────────────────────────────────
public interface EnrollmentRepository
        extends JpaRepository<Enrollment, EnrollmentId> {

    List<Enrollment> findByIdStudentId(Long studentId);
    List<Enrollment> findByIdCourseId(Long courseId);

    @Query("SELECT e FROM Enrollment e WHERE e.id.studentId = :studentId " +
           "AND e.status = :status")
    List<Enrollment> findByStudentAndStatus(
        @Param("studentId") Long studentId,
        @Param("status")    EnrollmentStatus status);
}

// ── Service usage ─────────────────────────────────────────────────────
@Service
@RequiredArgsConstructor
public class EnrollmentService {

    private final EnrollmentRepository enrollmentRepo;

    public Enrollment enroll(Student student, Course course) {
        EnrollmentId id = new EnrollmentId(
            student.getId(), course.getId());

        if (enrollmentRepo.existsById(id)) {
            throw new DuplicateResourceException(
                "Student is already enrolled in this course");
        }

        Enrollment enrollment = new Enrollment();
        enrollment.setId(id);
        enrollment.setStudent(student);
        enrollment.setCourse(course);
        enrollment.setStatus(EnrollmentStatus.ACTIVE);
        enrollment.setEnrolledAt(LocalDateTime.now());
        return enrollmentRepo.save(enrollment);
    }
}

Strategy Comparison and Selection Guide

Choosing the right primary key strategy depends on the database, throughput requirements, and whether the system is distributed. This section summarises the trade-offs and provides a decision guide.
Java
// ── Strategy comparison ───────────────────────────────────────────────
//
// Strategy   DB Support       Batch Insert  Distributed  Gaps   Notes
// ─────────────────────────────────────────────────────────────────────
// IDENTITY   MySQL, PG, etc.  No            No           Yes    Simplest
// SEQUENCE   PG, Oracle, H2   Yes           No           Yes    Best for PG
// AUTO       All              Varies        No           Yes    Hibernate chooses
// TABLE      All              No            No           Yes    Avoid — slow
// UUID v4    All              Yes           Yes          N/A    Random; large
// UUID v7    All              Yes           Yes          N/A    Time-ordered
// Assigned   All              Yes           Yes          No     Natural keys

// ── Decision guide ────────────────────────────────────────────────────
//
// Single-instance app + MySQL      → IDENTITY
// Single-instance app + PostgreSQL → SEQUENCE (allocationSize ≥ 50)
// Distributed system / microservice → UUID (v7 preferred)
// Natural business key exists      → Assigned + Persistable
// Join table / associative entity  → Composite (@IdClass or @EmbeddedId)
// Legacy schema                    → Match whatever the DB already uses

// ── SEQUENCE configuration for batch inserts ──────────────────────────
// application.yml:
// spring:
//   jpa:
//     properties:
//       hibernate:
//         jdbc:
//           batch_size: 50
//         order_inserts: true   # group INSERTs by entity type
//         order_updates: true   # group UPDATEs by entity type
//         generate_statistics: false

// ── @GeneratedValue on Long vs Integer vs UUID ────────────────────────
// Long (8 bytes)   — standard; 9.2 × 10^18 values; use for most entities
// Integer (4 bytes)— only 2.1 × 10^9 values; risky for high-volume tables
// UUID (16 bytes)  — globally unique; larger index; use for distributed IDs