Spring Data Redis is the Spring module that connects a Spring Boot application to a Redis server through one of two Java clients, Lettuce or Jedis. Spring Boot picks Lettuce by default, creates the connection, and gives us a StringRedisTemplate and a RedisTemplate to read and write keys. We switch to Jedis with one property, spring.data.redis.client-type=jedis, and our code stays the same.
We use Spring Data Redis to cache data in front of a slow database, to store sessions and counters that many app instances share, and to keep values that expire on their own after a set time. Redis holds everything in memory, so a read takes well under a millisecond.
The following example stores a recipe name with a 10 minute expiry and counts its views, using the auto-configured StringRedisTemplate. Each line shows the value Redis returns.
stringRedisTemplate.opsForValue().set("recipe:1:name", "Pancakes", Duration.ofMinutes(10));
String name = stringRedisTemplate.opsForValue().get("recipe:1:name"); // "Pancakes"
Long ttl = stringRedisTemplate.getExpire("recipe:1:name"); // 600 (seconds left)
Long views = stringRedisTemplate.opsForValue().increment("recipe:1:views"); // 1
Boolean removed = stringRedisTemplate.delete("recipe:1:name"); // true
Notice that we did not write any connection code. Spring Boot read spring.data.redis.host and spring.data.redis.port from application.properties and built the connection factory for us.
We start with the choice between Lettuce and Jedis, run Redis in Docker, and go through the connection properties. After that we set up a connection pool with commons-pool2, store objects as JSON with RedisTemplate, add a @RedisHash repository, and test the code against a Redis container.
1. Lettuce vs Jedis
Both clients send the same commands to Redis, and with Spring Data Redis we call the same RedisTemplate methods either way. The difference is in how each client handles connections. That decides whether we need a connection pool, and whether the app can use the non-blocking reactive API later.
Lettuce is built on Netty, a networking library that lets many threads send commands over one connection. So Spring Data Redis opens a single Lettuce connection and shares it between all threads for normal commands. Jedis uses a plain socket, and one Jedis object must not be used by two threads at the same time. So Jedis always works with a pool of connections, and each call borrows one.
| Lettuce | Jedis | |
|---|---|---|
| Spring Boot default | Yes, comes with spring-boot-starter-data-redis | No, add the jedis dependency and set spring.data.redis.client-type=jedis |
| Thread safety | One connection is thread-safe and shared by all threads | A Jedis instance is not thread-safe, so every call borrows a connection from a pool |
| Programming model | Synchronous, asynchronous and reactive (Project Reactor) | Synchronous only |
| Connection pool | Optional, used for transactions and blocking commands such as BLPOP | Required, always on |
| Sentinel | Yes, can also read from replicas | Yes, master lookup only |
| Cluster | Yes | Yes |
| Master and replica list (no Sentinel) | Yes, RedisStaticMasterReplicaConfiguration | No |
| SSL | Yes | Yes |
| Managed version in Spring Boot 4.1.1 | Lettuce 7.5.2 | Jedis 7.4.1 |
Use Lettuce unless we have a reason not to. It is the default, it needs no pool for everyday commands, and it is the only choice for a reactive app or for reading from replicas. Jedis is a good fit when a team already runs it in other services, or when a library we depend on is built on redis.clients.jedis classes. The cost of Lettuce is a few extra threads for Netty, and that is all a Spring Boot app notices.
The following example is a small recipe cache built with Spring Boot 4.1.1, Spring Data Redis 4.1.1, Java 25 and Redis 8. It stores recipe names as plain strings, whole Recipe objects as JSON, and a Recipe entity as a Redis hash through a repository. The same code runs with both clients.

2. Redis Setup
For local development, the quickest way to get a Redis server is the official Docker image. One command starts Redis on port 6379, which is the port Spring Boot connects to by default.
docker run -d --name redis-demo -p 6379:6379 redis:latest
docker exec redis-demo redis-cli PING # PONG
We can also install Redis from the download page on Linux and macOS. Redis does not run on Windows without WSL or Docker.
In tests, we do not want a server that someone has to start by hand. Testcontainers starts a throwaway Redis container for the test run and stops it afterwards, as we see in section 7.
3. Maven
Spring Boot supports two connectors, and the starter brings the Lettuce one. With spring-boot-starter-data-redis alone we get Lettuce, Spring Data Redis and the auto-configuration, so this one dependency is enough for most apps.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
For Jedis, we add the client library next to the starter. Spring Boot manages the version, so we leave it out. When both clients are on the classpath, Spring Boot still picks Lettuce, and we switch with the spring.data.redis.client-type property in section 5.3.
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
The connection pool for both clients comes from Apache Commons Pool. Spring Boot turns pooling on as soon as commons-pool2 is on the classpath.
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
For the tests we add the Redis test starter and the Spring Boot Testcontainers module. Spring Boot 4 manages the Testcontainers 2 version as well.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
4. Spring Boot Configuration
Spring Boot auto-configures a RedisConnectionFactory, a StringRedisTemplate and a RedisTemplate<Object, Object> bean, and we inject them wherever we need Redis. The connection factory holds the client (Lettuce or Jedis) and the server address. The templates use the connection factory to send commands and turn our Java keys and values into bytes.
Once created, RedisTemplate and StringRedisTemplate are thread-safe, so one instance serves the whole application.
4.1. Spring Data Redis Properties
By default, the connection factory connects to a Redis server at localhost:6379 with no password and database 0. We change any of that in application.properties with the spring.data.redis. keys. Older tutorials use the spring.redis. prefix, which Spring Boot 3 and 4 ignore, as we see in section 8.3.
spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.data.redis.database=0
spring.data.redis.username=user
spring.data.redis.password=secret
spring.data.redis.connect-timeout=2s
spring.data.redis.timeout=2s
The two timeouts matter in production. Without them, a call to an unreachable Redis server waits for the client default, which is 60 seconds in Lettuce, before Spring throws a RedisConnectionFailureException. Spring Boot has more spring.data.redis. properties than the table shows, and the table keeps the ones we set most often.
| Property | Default | What it sets |
|---|---|---|
| spring.data.redis.host | localhost | Server host name |
| spring.data.redis.port | 6379 | Server port |
| spring.data.redis.database | 0 | Database index (0 to 15 on a default server) |
| spring.data.redis.username | Login user name (Redis 6 ACL users) | |
| spring.data.redis.password | Login password | |
| spring.data.redis.url | One URL such as redis://user:secret@host:6379/0 that overrides host, port, username, password and database | |
| spring.data.redis.connect-timeout | Time to wait for the TCP connection | |
| spring.data.redis.timeout | Read timeout, the longest time one command may take | |
| spring.data.redis.client-type | detected | lettuce or jedis |
| spring.data.redis.client-name | Name shown by the CLIENT LIST command on the server | |
| spring.data.redis.ssl.enabled | false | Connect over TLS |
| spring.data.redis.sentinel.master | Master name when Redis runs behind Sentinel | |
| spring.data.redis.sentinel.nodes | Comma-separated host:port list of Sentinel nodes | |
| spring.data.redis.cluster.nodes | Comma-separated host:port list of cluster nodes | |
| spring.data.redis.lettuce.pool.* | Pool settings for Lettuce, see section 5 | |
| spring.data.redis.jedis.pool.* | Pool settings for Jedis | |
| spring.data.redis.repositories.enabled | true | Scan for @RedisHash repositories |
The connection factory also translates client exceptions into Spring’s DataAccessException family. So our service code catches RedisConnectionFailureException or QueryTimeoutException and never sees a Lettuce or Jedis class.
4.2. RedisTemplate
The RedisTemplate is the main class we work with. It borrows a connection from the factory, runs the command, releases the connection and converts the bytes back into Java objects. We inject the auto-configured one with the same @Autowired we use for any other bean.
@Autowired
private RedisTemplate<Object, Object> redisTemplate;
The default template turns keys and values into bytes with Java serialization, the same format ObjectOutputStream writes. So a key recipe:2 is stored on the server as bytes that redis-cli cannot show as text. For a key we want to see on the server and a value another app can parse, we define our own template with a String key serializer and a JSON value serializer. Spring Data Redis 4 ships JacksonJsonRedisSerializer for Jackson 3, and it needs spring-boot-starter-jackson on the classpath.
@Bean
public RedisTemplate<String, Recipe> recipeRedisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Recipe> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new JacksonJsonRedisSerializer<>(Recipe.class));
template.setHashValueSerializer(new JacksonJsonRedisSerializer<>(Recipe.class));
return template;
}
Our bean is named recipeRedisTemplate, so the auto-configured redisTemplate bean stays as it is. Spring Boot backs off from RedisTemplate only when it finds a bean with the name redisTemplate, not one of the same type. With the typed template, we store a Recipe and get a Recipe back, and the value on the server is plain JSON.
recipeRedisTemplate.opsForValue().set("recipe:2", new Recipe("2", "Omelette", 10));
Recipe found = recipeRedisTemplate.opsForValue().get("recipe:2"); // Recipe[id=2, name=Omelette, minutes=10]
String raw = stringRedisTemplate.opsForValue().get("recipe:2"); // {"id":"2","name":"Omelette","minutes":10,"ttlSeconds":null}
The org.springframework.data.redis.serializer package has a few more serializers. GenericJacksonJsonRedisSerializer writes JSON with the class name inside, for templates that store values of mixed types, and GenericToStringSerializer stores numbers and other simple values as text. For any other format, we implement the two methods of RedisSerializer<T> ourselves, serialize() to bytes and deserialize() from bytes.
4.3. StringRedisTemplate
The StringRedisTemplate is a RedisTemplate<String, String> that uses StringRedisSerializer for keys, values, hash keys and hash values. Both ends are plain UTF-8 text, so it is the right choice for names, counters, tokens and anything else we would store as a string anyway. Spring Boot auto-configures one, and we inject it by type.
@Autowired
private StringRedisTemplate stringRedisTemplate;
Redis keeps a counter as the text of the number, so increment() works on a value we stored as a string, and redis-cli or an app in another language can read it.
4.4. Cache Operations
Redis has more data types than a plain key-value map, and the template groups the commands for each type behind an opsForX() method. Each group returns an operations interface with the commands for that type.
| Redis type | Template method | Operations interface | Typical commands |
|---|---|---|---|
| String | opsForValue() | ValueOperations | set(), get(), increment(), setIfAbsent() |
| Hash | opsForHash() | HashOperations | put(), get(), entries(), delete() |
| List | opsForList() | ListOperations | leftPush(), rightPop(), range() |
| Set | opsForSet() | SetOperations | add(), members(), isMember() |
| Sorted set | opsForZSet() | ZSetOperations | add(), rangeByScore(), rank() |
| Geo | opsForGeo() | GeoOperations | add(), distance(), radius() |
| Stream | opsForStream() | StreamOperations | add(), read(), acknowledge() |
Key-level commands such as expire(), getExpire(), delete() and hasKey() sit on the template itself, because they apply to any type. We can see the hash group at work when we put two recipes into one hash and load the whole menu with one entries() call.
recipeRedisTemplate.opsForHash().put("menu:monday", "2", new Recipe("2", "Omelette", 10));
recipeRedisTemplate.opsForHash().put("menu:monday", "3", new Recipe("3", "Lemonade", 5));
Map<Object, Object> menu = recipeRedisTemplate.opsForHash().entries("menu:monday"); // size 2
Recipe lemonade = (Recipe) menu.get("3"); // Recipe[id=3, name=Lemonade, minutes=5]
Boolean expired = recipeRedisTemplate.expire("menu:monday", Duration.ofHours(1)); // true
When a class needs only one group, we can inject that group with @Resource and skip the opsForX() call each time. The annotation takes the template bean name.
@Resource(name = "stringRedisTemplate")
private ValueOperations<String, String> valueOps;
5. Connection Pool with commons-pool2
A pool keeps a few open connections ready, so a request does not wait for a new TCP handshake. With Lettuce, the shared connection already serves normal commands. The pool is used only for commands that need a connection of their own, which are Redis transactions (MULTI and EXEC) and blocking commands such as BLPOP, which waits until a list has an element. With Jedis, every single command borrows a connection from the pool.
5.1. Pool Properties
Spring Boot creates the pool when commons-pool2 is on the classpath and reads its settings from spring.data.redis.lettuce.pool. or spring.data.redis.jedis.pool.. The default pool is small, 8 connections at most, which is fine to start with. A busy service with many threads waiting on Redis needs more.
spring.data.redis.lettuce.pool.enabled=true
spring.data.redis.lettuce.pool.max-active=16
spring.data.redis.lettuce.pool.max-idle=8
spring.data.redis.lettuce.pool.min-idle=2
spring.data.redis.lettuce.pool.max-wait=2s
spring.data.redis.lettuce.pool.time-between-eviction-runs=30s
| Property | Default | Meaning |
|---|---|---|
| pool.enabled | true when commons-pool2 is present | Turns pooling on or off |
| pool.max-active | 8 | Connections that may be in use at the same time, -1 for no limit |
| pool.max-idle | 8 | Idle connections the pool keeps open |
| pool.min-idle | 0 | Idle connections the pool creates ahead of time, needs time-between-eviction-runs |
| pool.max-wait | -1ms (wait forever) | How long a caller waits for a free connection before a PoolException |
| pool.time-between-eviction-runs | How often the pool closes idle connections above min-idle |
Set max-wait to a few seconds. With the default, every caller waits forever when the pool is empty and Redis is stuck, and the app stops responding.
5.2. Customizing LettuceConnectionFactory
Spring Boot builds the LettuceConnectionFactory from the properties, and most of the time we do not replace it. To change the Lettuce client in code, for example to set the command timeout and the client name in one place, we register a LettuceClientConfigurationBuilderCustomizer bean. Spring Boot calls it with the builder it is about to use, so the pool and the properties stay in place, and a setting from the customizer wins over the property.
@Bean
public LettuceClientConfigurationBuilderCustomizer lettuceCustomizer() {
return builder -> builder
.commandTimeout(Duration.ofSeconds(2))
.clientName("recipe-app");
}
When we need full control, for example two Redis servers in one app, we define our own LettuceConnectionFactory bean. In that case Spring Boot backs off from its own factory. The pool goes into a LettucePoolingClientConfiguration, and the server address into a RedisStandaloneConfiguration.
@Bean
public LettuceConnectionFactory redisConnectionFactory() {
GenericObjectPoolConfig<StatefulConnection<?, ?>> poolConfig = new GenericObjectPoolConfig<>();
poolConfig.setMaxTotal(16);
poolConfig.setMaxIdle(8);
poolConfig.setMinIdle(2);
poolConfig.setMaxWait(Duration.ofSeconds(2));
LettucePoolingClientConfiguration clientConfig = LettucePoolingClientConfiguration.builder()
.poolConfig(poolConfig)
.commandTimeout(Duration.ofSeconds(2))
.build();
RedisStandaloneConfiguration serverConfig = new RedisStandaloneConfiguration("localhost", 6379);
// serverConfig.setPassword("secret"); when the server needs a password
return new LettuceConnectionFactory(serverConfig, clientConfig);
}
Redis Sentinel is the Redis service that watches a master server and promotes a replica when the master fails. A server behind Sentinel takes a RedisSentinelConfiguration in place of the standalone one, and a Redis cluster takes a RedisClusterConfiguration. With Spring Boot, the spring.data.redis.sentinel.* and spring.data.redis.cluster.* properties do the same without a bean.
The JedisConnectionFactory follows the same pattern with JedisClientConfiguration and a JedisClientConfigurationBuilderCustomizer.
5.3. Switching to Jedis
With the jedis dependency from section 3 on the classpath, one property makes Spring Boot build a JedisConnectionFactory instead of the Lettuce one. The Jedis pool is on by default and reads the spring.data.redis.jedis.pool.* keys.
spring.data.redis.client-type=jedis
spring.data.redis.jedis.pool.max-active=16
spring.data.redis.jedis.pool.max-idle=8
spring.data.redis.jedis.pool.min-idle=2
spring.data.redis.jedis.pool.max-wait=2s
Nothing else changes, because our code calls RedisTemplate and never a Lettuce or Jedis class. We can check which client is active by injecting the RedisConnectionFactory and printing its class.
String client = connectionFactory.getClass().getSimpleName(); // LettuceConnectionFactory, or JedisConnectionFactory
6. Redis Repository with @RedisHash
For objects we want to load by id, Spring Data Redis offers the same CrudRepository style we know from JPA. We mark the class with @RedisHash, give it an @Id field, and declare a repository interface. Spring stores every object as a Redis hash under the key recipe:{id}, with one hash field per Java field, and keeps the ids in a set named recipe for findAll() and count().
@RedisHash("recipe")
public class Recipe {
@Id
private String id;
private String name;
private int minutes;
@TimeToLive
private Long ttlSeconds;
// constructors, getters and setters
}
public interface RecipeRepository extends CrudRepository<Recipe, String> {
}
The @TimeToLive field sets the expiry of the hash in seconds, and null means no expiry. We can see that the repository API hides the hash commands.
Recipe recipe = new Recipe("3", "Lemonade", 5);
recipe.setTtlSeconds(300L);
Recipe saved = repository.save(recipe); // Recipe[id=3, name=Lemonade, minutes=5]
Optional<Recipe> found = repository.findById("3"); // Optional[Recipe[id=3, ...]]
long count = repository.count(); // 1
Long ttl = stringRedisTemplate.getExpire("recipe:3"); // 300 (seconds left)
Redis repositories give us save(), findById(), findAll(), count() and delete() from the standard interface, and finder methods such as findByName() on fields marked with @Indexed. There are no joins, so for anything beyond lookups by id or by an indexed field we go back to the template.
7. Testing with a Redis Container
A Spring Boot test for Redis code needs a real server, because the serializers and the pool only show their bugs on a live connection. Testcontainers starts the redis:8.2 image on a random free port. Spring Boot’s @ServiceConnection reads that host and port from the container and uses them for the connection factory, so the test needs no spring.data.redis.host at all.
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfiguration {
@Bean
@ServiceConnection(name = "redis")
GenericContainer<?> redisContainer() {
return new GenericContainer<>("redis:8.2").withExposedPorts(6379);
}
}
The name attribute tells Spring Boot that this generic container is a Redis server. The test imports that configuration and checks the client, the pool and the values.
@SpringBootTest
@Import(TestcontainersConfiguration.class)
class LettuceRedisTemplateTest {
@Autowired
RedisConnectionFactory connectionFactory;
@Autowired
RecipeCacheService service;
@Test
void usesLettuceWithConnectionPool() {
assertThat(connectionFactory).isInstanceOf(LettuceConnectionFactory.class);
LettuceConnectionFactory lettuce = (LettuceConnectionFactory) connectionFactory;
assertThat(lettuce.getClientConfiguration()).isInstanceOf(LettucePoolingClientConfiguration.class);
assertThat(lettuce.getClientName()).isEqualTo("recipe-app");
}
@Test
void storesAndExpiresStringValues() {
service.saveName("recipe:1:name", "Pancakes", Duration.ofMinutes(10));
assertThat(service.findName("recipe:1:name")).isEqualTo("Pancakes");
assertThat(service.remainingSeconds("recipe:1:name")).isBetween(590L, 600L);
assertThat(service.countView("recipe:1:views")).isEqualTo(1L);
assertThat(service.countView("recipe:1:views")).isEqualTo(2L);
}
}
A second test class sets spring.data.redis.client-type=jedis in its @SpringBootTest properties and runs the same service against a JedisConnectionFactory. The property makes Spring start a second application context for the Jedis test, with its own Redis container, so the two clients never share a server.
@SpringBootTest(properties = "spring.data.redis.client-type=jedis")
@Import(TestcontainersConfiguration.class)
class JedisClientTest {
@Autowired
RedisConnectionFactory connectionFactory;
@Test
void usesJedisWithConnectionPool() {
assertThat(connectionFactory).isInstanceOf(JedisConnectionFactory.class);
JedisConnectionFactory jedis = (JedisConnectionFactory) connectionFactory;
assertThat(jedis.getPoolConfig().getMaxTotal()).isEqualTo(16);
}
}
Running mvn test with Docker up runs seven tests, which all pass, across the Lettuce, repository and Jedis classes.
tc.redis:8.2 : Container redis:8.2 started in PT0.420825672S
Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 -- in com.howtodoinjava.demo.JedisClientTest
Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 -- in com.howtodoinjava.demo.LettuceRedisTemplateTest
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 -- in com.howtodoinjava.demo.RecipeRepositoryTest
Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Outside the tests, a CommandLineRunner in the example project writes the same keys to the Docker server from section 2, so we can look at them with redis-cli.
docker exec redis-demo redis-cli KEYS '*'
# recipe:1:name recipe:1:views recipe:2 recipe:3 recipe
docker exec redis-demo redis-cli GET recipe:2
# {"id":"2","name":"Omelette","minutes":10,"ttlSeconds":null}
docker exec redis-demo redis-cli HGETALL recipe:3
# _class com.howtodoinjava.demo.model.Recipe id 3 minutes 5 name Lemonade
Stop and remove the container with docker rm -f redis-demo when we are done.
8. Lettuce vs Jedis FAQs
8.1. Does Lettuce Need a Connection Pool?
No, not for ordinary commands. Lettuce shares one thread-safe connection between all threads, as we saw in section 5. We add commons-pool2 when the app runs Redis transactions, uses blocking commands such as BLPOP or BRPOP, or sets shareNativeConnection to false on the factory to give every call its own connection.
8.2. Which Lettuce and Jedis Versions Work With Spring Boot 4?
The versions that Spring Boot manages for us. Spring Boot 4.1.1 manages Lettuce 7.5.2, Jedis 7.4.1 and Spring Data Redis 4.1.1, so we never write those versions in the pom.xml. Pairs such as Spring Data Redis 1.8 with Lettuce 4.5 belong to Spring Boot 1.5, which reached end of life in 2019. The safe rule is to upgrade Spring Boot and let it pick the client version.
8.3. Why Does spring.redis.host Have No Effect?
Because the prefix changed. Spring Boot 3.0 moved every Redis property from spring.redis. to spring.data.redis., and the old keys are ignored, so the app keeps connecting to localhost:6379. Rename the keys in application.properties and in any environment variables such as SPRING_REDIS_HOST, which becomes SPRING_DATA_REDIS_HOST.
8.4. Can We Use Redis as the Spring Cache?
Yes. With spring-boot-starter-cache and the Redis starter on the classpath, Spring Boot creates a RedisCacheManager over the same connection factory, and @Cacheable methods store their results in Redis. The Spring Boot caching article covers the annotations and the RedisCacheConfiguration for the time to live.
9. Conclusion
Spring Boot connects to Redis with Lettuce by default and with Jedis after one property change, and in both cases our code calls RedisTemplate and StringRedisTemplate. Lettuce is the better default because it shares one thread-safe connection and supports the reactive API, whereas Jedis needs a pool for every command.
The spring.data.redis.* properties cover the host, the credentials, the timeouts and the pool. For values we want to see on the server, a typed RedisTemplate with a JSON serializer keeps them readable, and a Testcontainers test checks the whole setup against a real server.
10. References
- Spring Boot reference, Redis
- Spring Data Redis reference, Connectors
- Spring Data Redis reference, Redis Repositories
- Spring Boot application properties, Data
- Lettuce on GitHub
- Jedis on GitHub
- Redis Docker image
Happy Learning !!