Database Indexing with Spring Boot and JPA
A practical guide to how database indexes work, when to use them, and how to define and verify indexes in Spring Boot with JPA.
As a database grows, queries that once felt instant can become noticeably slower. Indexing is one of the most effective ways to keep read performance predictable, but an index is not free: it uses storage and adds work to every write. This guide explains how indexes work, how to use them from Spring Boot, and how to decide which indexes are worth keeping.
What is a database index?
A database index is similar to the index at the back of a book. Instead of reading every page to find a topic, you use an ordered list that points to the right pages. In the same way, a database can consult an index to locate matching rows without scanning the entire table.
An index is a separate data structure containing values from one or more columns plus references to their rows. Most relational databases commonly use B-tree indexes, which are efficient for equality checks, ranges, and sorting. Some databases also provide hash, full-text, spatial, and specialized index types for different access patterns.
Common types of indexes
- Single-column index: useful when queries frequently filter, join, or sort by one column, such as email.
- Composite index: contains multiple columns in a defined order. An index on (last_name, first_name) can serve queries using last_name alone or last_name together with first_name, but usually not queries filtering only by first_name. This is known as the leftmost-prefix rule.
- Unique index: improves lookup performance while also enforcing that indexed values, or value combinations, do not repeat.
- Clustered index: determines how table rows are physically organized. A table can generally have only one clustered order, and the exact behavior depends on the database engine.
- Non-clustered index: stores its own ordered keys and row references without changing the table's physical row order. A table can have several of these.
Why indexing improves performance
Without a useful index, the database may perform a sequential scan and inspect every row. As the table grows, the work grows with it. With a suitable index, the database can navigate a much smaller structure, find the matching keys, and fetch only the required rows. This can reduce a query from seconds to milliseconds on a large table.
The trade-off appears during INSERT, UPDATE, and DELETE operations because every affected index must also be maintained. Indexes also consume disk space and memory. The goal is therefore not to index every column, but to support important query patterns with the smallest useful set of indexes.
Defining indexes with Spring Boot and JPA
Jakarta Persistence lets you declare indexes on an entity's @Table annotation. The columnList values refer to database column names, so explicit @Column names make the mapping unambiguous.
User.java
import jakarta.persistence.*;
@Entity
@Table(
name = "users",
indexes = {
@Index(name = "idx_users_email", columnList = "email"),
@Index(
name = "idx_users_last_name_first_name",
columnList = "last_name, first_name"
)
},
uniqueConstraints = {
@UniqueConstraint(name = "uk_users_email", columnNames = "email")
}
)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "email", nullable = false)
private String email;
@Column(name = "first_name")
private String firstName;
@Column(name = "last_name")
private String lastName;
}This mapping is convenient for local development and schema generation. In production, use a migration tool such as Flyway or Liquibase to create indexes explicitly, review the generated SQL, and apply changes consistently across environments.
Queries that can use these indexes
UserRepository.java
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
List<User> findByLastNameAndFirstName(
String lastName,
String firstName
);
List<User> findByLastNameOrderByFirstName(String lastName);
}The email lookup can use idx_users_email. The two last-name queries match the leading column of the composite index, and the database may also use the second column to narrow results or return them in order. Repository method names do not force index usage—the database optimizer chooses a plan based on the query, available indexes, statistics, and expected number of matching rows.
Verify the query plan
Do not assume that an index is helping simply because it exists. Use your database's query-plan tools with realistic data. In PostgreSQL, EXPLAIN ANALYZE runs the query and reports the actual plan and timing:
explain-index.sql
EXPLAIN ANALYZE
SELECT *
FROM users
WHERE email = 'example@gmail.com';Look for an Index Scan or Index Only Scan, then compare execution time and rows examined. A sequential scan is not automatically bad: for a small table or a query returning a large percentage of its rows, reading the table directly may be cheaper than jumping through an index.
Indexing best practices
- Start with real query patterns. Prioritize columns used frequently in WHERE clauses, JOIN conditions, and ORDER BY clauses on performance-critical paths.
- Choose composite column order deliberately. Put columns in an order that matches how important queries filter and sort; equality predicates commonly come before range predicates.
- Consider selectivity. An index on a column with only a few repeated values, such as a boolean flag, may provide little benefit unless it is a partial or filtered index.
- Avoid over-indexing. Duplicate and unused indexes increase write latency, storage use, backup size, and maintenance work.
- Watch for expressions and type conversions. Applying a function to an indexed column can prevent a normal index from being used unless the database has a matching expression index.
- Keep statistics current and monitor index usage. Revisit the strategy as data volume, value distribution, and application queries change.
A note about NULL values
NULL handling varies by database and index type. Most mainstream relational databases can include NULL values in B-tree indexes, but uniqueness rules and sort position differ. If queries target only a subset of rows, a database-specific partial or filtered index can be more efficient—for example, indexing only active users or rows where a value is not NULL.
Conclusion
Effective indexing begins with the queries your application actually runs. Add an index that matches an important access pattern, measure the plan and timing, and keep it only when the read benefit justifies its write and storage cost. As the application evolves, treat indexing as an ongoing performance practice rather than a one-time setup task.
Comments