Blog

Spring Data JPA CRUD Example

Key Takeaways

  • Extending CrudRepository gives your repository standard CRUD operations without an implementation class.
  • Use the entity returned by save; findById returns an Optional that can be empty.
  • Repository transactions do not make a sequence of startup-runner calls atomic. Put business operations in a transactional service.
  • The example includes a complete Maven setup, an embedded H2 database, and a test that verifies all four operations.

To perform CRUD with Spring Data JPA, define a JPA entity and a repository extending CrudRepository<User, Long>, then call save, findById, and delete. Spring Data JPA supplies the repository implementation; you don't write those methods yourself.

This Spring Data JPA CRUD example creates Alice, reads the saved user, changes the name to Ted, and deletes the record. It keeps the repository-focused approach from our Spring Data JPA overview, with enough project setup to run it yourself.

How Spring Data JPA Performs CRUD

JPA defines persistence mappings and entity behavior. Spring Data JPA adds the repository abstraction, while Hibernate is the JPA provider used by this application's starter.

Application
    -> Spring Data JPA repository
        -> Hibernate / JPA EntityManager
            -> SQL through JDBC
                -> H2 database

These are different layers, not competing ways to name the same library. See Spring Data JPA vs Hibernate for a fuller comparison.

Project Setup and Compatibility

Use Spring Boot 4.1.1, Java 25, H2 2.4.240, and Maven 3.6.3 or later for this example. Boot's system requirements cover this Java/Maven combination, and its managed dependency list supplies the H2 version. Run the included integration test to verify your local setup.

Create this directory structure. Keep Application above the entity and repository packages so Boot can discover them.

pom.xml
src/main/java/userservice/Application.java
src/main/java/userservice/model/User.java
src/main/java/userservice/repository/UserRepository.java
src/main/resources/application.yaml
src/test/java/userservice/repository/UserRepositoryTest.java

Add the Maven Dependencies

Save this as pom.xml. The Boot parent manages dependency and plugin versions; the JPA test starter supplies the test slice and general testing libraries.

<project>
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.1</version>
        <relativePath/>
    </parent>

    <groupId>com.stackchief</groupId>
    <artifactId>jpa-crud-example</artifactId>
    <version>1.0.0</version>

    <properties>
        <java.version>25</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Configure the H2 Database

Save this as src/main/resources/application.yaml:

spring:
  datasource:
    url: jdbc:h2:mem:crud;DB_CLOSE_ON_EXIT=FALSE
  jpa:
    hibernate:
      ddl-auto: none
    show-sql: true
---
spring:
  config:
    activate:
      on-profile: demo
  jpa:
    hibernate:
      ddl-auto: create-drop

This uses an in-memory database, so you don't need a separate database server. Boot can infer the driver from the JDBC URL; create-drop creates the mapped schema and drops it when the persistence infrastructure shuts down. This configuration is deliberately disposable, as described in Boot's SQL database documentation.

The base document disables Hibernate schema generation with ddl-auto: none; the second document enables create-drop only when the demo profile is active, using profile-activated configuration. The test below configures its own schema generation without enabling the demo runner.

User Entity

An entity is a Java object mapped to database storage. Save this class as src/main/java/userservice/model/User.java:

package userservice.model;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "app_user")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

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

    protected User() {
        // Required by JPA.
    }

    public User(String name) {
        setName(name);
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        if (name == null || name.isBlank() || name.length() > 100) {
            throw new IllegalArgumentException(
                    "Name must be nonblank and at most 100 characters");
        }
        this.name = name;
    }
}
  • JPA requires a public or protected no-argument constructor.
  • Placing the mapping annotations on fields selects field access. Ordinary persistent fields don't require individual mapping annotations; column names default to field names, subject to the configured naming strategy.
  • Long lets a newly constructed user's ID remain null. For this unversioned entity, that null ID identifies it as new.
  • @Column describes database mapping constraints. The setter separately rejects null, blank, or overlong input before a repository call.

The Jakarta Persistence specification defines these mapping rules. The explicit table name also avoids USER, which is a keyword in H2.

Why Use GenerationType.IDENTITY?

IDENTITY delegates identifier generation to a database identity column. With Hibernate, the row must be inserted before that identifier is known; in the runner below, the completed repository save returns a user with its generated ID.

User Repository

Save this as src/main/java/userservice/repository/UserRepository.java:

package userservice.repository;

import org.springframework.data.repository.CrudRepository;
import userservice.model.User;

public interface UserRepository extends CrudRepository<User, Long> {
}

User is the entity type and Long is its identifier type. Spring Data JPA creates the repository implementation at runtime, as demonstrated in Spring's official JPA getting-started guide.

The inherited CrudRepository API includes:

  • save(user): saves an entity and returns the saved instance.
  • findById(id): returns an Optional<User>, empty when no user exists.
  • delete(user): deletes the persisted entity.
  • existsById(id): checks whether an entity exists.
  • findAll(): returns an Iterable<User>.

CrudRepository vs JpaRepository

InterfaceWhat it providesWhen to choose it
CrudRepositoryBasic CRUD methods; collection results such as findAll use Iterable.A minimal repository such as this example.
JpaRepositoryCRUD with List-returning collection methods, pagination and sorting, plus JPA operations such as flush and saveAndFlush.You need the broader JPA-specific API.

You don't need to switch to JpaRepository just to update or delete an entity.

CRUD Examples: Create, Read, Update, and Delete

Save this as src/main/java/userservice/Application.java. The runner is enabled only under the demo profile, so ordinary test runs don't execute startup CRUD.

package userservice;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Profile;
import userservice.model.User;
import userservice.repository.UserRepository;

@SpringBootApplication
public class Application {
    private static final Logger log =
            LoggerFactory.getLogger(Application.class);

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

    @Bean
    @Profile("demo")
    CommandLineRunner demo(UserRepository repo) {
        return args -> {
            // CREATE
            User created = repo.save(new User("Alice"));
            log.info("CREATE id={} name={}",
                    created.getId(), created.getName());

            // READ
            User found = repo.findById(created.getId())
                    .orElseThrow(() -> new IllegalStateException(
                            "Created user could not be read"));
            log.info("READ id={} name={}",
                    found.getId(), found.getName());

            // UPDATE
            found.setName("Ted");
            User updated = repo.save(found);
            log.info("UPDATE id={} name={}",
                    updated.getId(), updated.getName());

            // DELETE
            repo.delete(updated);
            log.info("DELETE exists={}",
                    repo.existsById(updated.getId()));
        };
    }
}

From the project directory, use the Spring Boot Maven run goal with the demo profile:

mvn spring-boot:run -Dspring-boot.run.profiles=demo

Among the startup and SQL messages, expect these application messages. The generated identifier is represented here as a placeholder, not a fixed value:

CREATE id=<generated-id> name=Alice
READ id=<generated-id> name=Alice
UPDATE id=<generated-id> name=Ted
DELETE exists=false

Logging the ID and name explicitly avoids relying on an entity's default toString() representation.

How save Handles Inserts and Updates

Spring Data JPA's entity-state detection determines whether save calls EntityManager.persist or EntityManager.merge. For this entity, a null ID selects persist; an existing ID selects merge. Other entity models can also use nullable version properties when determining newness.

Always continue with the instance returned by save, because merge can return a different managed instance. Save isn't an existence check or a guaranteed SQL upsert: load the entity first when an update must fail if its ID doesn't exist.

Transaction Boundaries and the Persistence Context

The runner is not transactional. Inherited repository read operations are read-only transactions by default, while write operations use ordinary transactions. Without an outer transaction, the runner's writes commit independently; a later failure doesn't undo earlier saves.

A persistence context tracks managed entity instances and their changes. In this non-web runner, entities become detached when their repository transaction's persistence context closes; changing a detached object alone doesn't persist an update. The later save merges its state.

Use a Service for One Business Transaction

For a read-modify-write business operation, add src/main/java/userservice/service/UserService.java:

package userservice.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import userservice.model.User;
import userservice.repository.UserRepository;

@Service
public class UserService {
    private final UserRepository repo;

    public UserService(UserRepository repo) {
        this.repo = repo;
    }

    @Transactional
    public User rename(Long id, String name) {
        User user = repo.findById(id)
                .orElseThrow(() -> new UserNotFoundException(id));
        user.setName(name);
        return repo.save(user);
    }

    public static class UserNotFoundException extends RuntimeException {
        public UserNotFoundException(Long id) {
            super("User not found: " + id);
        }
    }
}

Call rename through an injected service bean so Spring can apply transaction interception. Both repository calls then participate in the service transaction. Within that transaction, JPA can persist changes to the managed user without an explicit save; keeping the call makes the repository intent clear. Spring Data's transactionality documentation explains these boundaries.

Test All Four CRUD Operations

Save this as src/test/java/userservice/repository/UserRepositoryTest.java. Boot's Data JPA test support configures the repository and embedded database; tests are transactional and roll back by default.

package userservice.repository;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
import userservice.model.User;

import static org.assertj.core.api.Assertions.assertThat;

@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop")
class UserRepositoryTest {
    @Autowired
    private UserRepository repo;

    @Autowired
    private TestEntityManager entityManager;

    @Test
    void createsReadsUpdatesAndDeletesUser() {
        // CREATE
        User created = repo.save(new User("Alice"));
        Long id = created.getId();
        assertThat(id).isNotNull();
        entityManager.flush();
        entityManager.clear();

        // READ
        User found = repo.findById(id).orElseThrow();
        assertThat(found.getName()).isEqualTo("Alice");

        // UPDATE
        found.setName("Ted");
        User updated = repo.save(found);
        assertThat(updated.getId()).isEqualTo(id);
        entityManager.flush();
        entityManager.clear();

        User reread = repo.findById(id).orElseThrow();
        assertThat(reread.getName()).isEqualTo("Ted");

        // DELETE
        repo.delete(reread);
        entityManager.flush();
        entityManager.clear();
        assertThat(repo.findById(id)).isEmpty();
    }
}

Run it with:

mvn test

Flush sends pending changes to the database; clear detaches managed instances so subsequent reads don't simply reuse the persistence context's existing objects. Flush does not commit the transaction. This verifies database interaction rather than just checking changed Java fields.

Add Custom Repository Queries When Needed

Standard CRUD needs no additional methods. For name lookups, you can optionally replace UserRepository.java with this version:

package userservice.repository;

import java.util.List;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import userservice.model.User;

public interface UserRepository extends CrudRepository<User, Long> {
    @Transactional(readOnly = true)
    List<User> findByName(String name);

    @Transactional(readOnly = true)
    @Query("select u from User u where u.name = :name")
    List<User> lookupByName(@Param("name") String name);
}

findByName derives its query from the method name. lookupByName declares JPQL, which references the entity and its attributes rather than the app_user table. Declared query methods don't inherit the CRUD methods' transaction configuration, hence the explicit read-only annotations. See Spring Data JPA query methods for both approaches.

Once single-entity CRUD is comfortable, move on to the Spring Data JPA one-to-many example for related entities.

Production Checklist

  • Protect the schema. Keep the startup demo and create-drop configuration out of production. Use controlled migrations, such as Flyway or Liquibase, instead of treating automatic schema generation as a deployment strategy. Boot's database initialization guide covers the options.
  • Validate at the application boundary. For a web API, add spring-boot-starter-validation, constrain the request name with NotBlank and Size, and enable request validation with Valid. Keep database constraints as a separate safeguard.
  • Define error responses and authorization. Map UserNotFoundException to HTTP 404 and invalid input to HTTP 400 rather than exposing the runner's demo exception. Spring MVC supports structured ProblemDetail error responses. Check permission to modify the user before updating or deleting it.
  • Don't infer not-found handling from deletion. CrudRepository.deleteById silently ignores a missing record; it doesn't automatically produce an HTTP 404 response.
  • Test concurrency and the real database. Consider a JPA Version field for optimistic locking, which detects conflicting versioned updates. Also run integration tests against your production database engine; an H2 test alone doesn't establish database-specific compatibility.

FAQ

How do I create, read, update, and delete an entity with Spring Data JPA?

Extend CrudRepository with your entity and ID types. Create with save, read with findById, update a loaded entity and save it, then delete the persisted entity with delete.

What is the difference between CrudRepository and JpaRepository?

CrudRepository exposes the basic CRUD contract. JpaRepository adds List-returning collection methods, pagination and sorting, and JPA-specific operations such as flush and saveAndFlush.

What dependencies and configuration do I need for a Spring Data JPA CRUD example?

The Boot 4 project shown here uses spring-boot-starter-data-jpa, the H2 runtime driver, and spring-boot-starter-data-jpa-test for tests. Configure an in-memory datasource and demo-only create-drop schema generation; no separate database server is needed.

Does CrudRepository save insert a new entity or update an existing one?

Spring Data JPA determines whether the entity is new and calls EntityManager.persist or EntityManager.merge accordingly. For the unversioned User shown here, a null ID selects persist; always use the entity returned by save.

Why does Spring Boot 3 use jakarta.persistence instead of javax.persistence?

Spring Boot 3 migrated to Jakarta EE APIs, whose persistence annotations use the jakarta.persistence package; Boot 4 continues that approach. Boot 2 examples use javax.persistence, so dependencies and imports must match your Boot generation.

Further Reading

Written by Sam Erickson

Software engineer. I write every article on StackChief myself. Author of Mastering the Tech Interview. More about me