Chat Memory Repositories
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Written against Spring AI 2.0.1, Spring Boot 4.1.x and Java 21 (verified 2026-09-29). The window logic of chat memory is in
Chat Memory; this page is about the layer under it, the ChatMemoryRepository that
stores the messages. It gives DocsAssistant durable, per-user conversations on the PostgreSQL database that already holds
its vector store, and then covers the other built-in stores, how to write your own, and the part production needs
most: how long conversations are kept and how one user’s data is erased. The Java on it was compiled against the 2.0.1 jars.
The JDBC repository, the ownership registry, the erasure service, the retention job and the custom repository were also
run, against H2 in PostgreSQL mode and the framework’s own H2 schema script rather than a PostgreSQL server; the
Cassandra, MongoDB, Neo4j and Redis classes were compiled, not run.
The ChatMemoryRepository contract
A ChatMemoryRepository has four methods and no opinion about windows or summaries, which belong to ChatMemory:
-
findConversationIds()returns the IDs of every conversation in the store, for all users; -
findByConversationId(id)returns the messages of one conversation, oldest first; -
saveAll(id, messages)stores the complete new content of a conversation. It replaces, it does not append: the JDBC implementation deletes the conversation’s rows and inserts the list in one transaction; -
deleteByConversationId(id)removes the conversation.
MessageWindowChatMemory calls findByConversationId, merges and trims in memory, and calls saveAll. Spring AI
auto-configures a ChatMemoryRepository (in-memory) and a ChatMemory on top of it, and both back off when you declare
your own bean of that type, or when a repository starter is on the classpath and configures one.
| Repository | Starter artifact | Tool messages | Retention support |
|---|---|---|---|
|
none, in |
stored |
none, gone on restart |
|
|
dropped on save |
none; schedule a purge |
|
|
dropped on save |
|
|
|
dropped on save |
|
|
|
stored |
none; schedule a purge |
|
|
stored |
|
The Azure Cosmos DB repository is no longer part of the Spring AI project since 2.0; it is maintained as an external module by the Cosmos DB team. "Dropped on save" means that assistant messages that carry tool calls and tool responses are silently left out, so those stores are only correct with the memory advisor outside the tool loop, which is the default (see Tool Calling Advisor & Tool Search). Source: Spring AI: Chat Memory.
JDBC
The JDBC repository is the natural fit for DocsAssistant, which already runs PostgreSQL 17 for pgvector. Add the starter, point the datasource at the database with credentials from the environment, and choose when the schema is created.
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
</dependency>
Source: Spring AI: Chat Memory.
spring:
datasource:
url: jdbc:postgresql://localhost:5432/docsassistant
username: ${DOCSASSISTANT_DB_USER}
password: ${DOCSASSISTANT_DB_PASSWORD}
ai:
chat:
memory:
repository:
jdbc:
initialize-schema: always # development only; use "never" with a migration tool in production
docsassistant:
memory:
max-messages: 20
Source: Spring AI: Chat Memory.
| Property | Meaning | Default |
|---|---|---|
|
|
|
|
Location of the script; supports |
|
|
Value for the placeholder |
detected |
|
Keep going when a script statement fails |
The table has one row per message. This is the PostgreSQL script from the 2.0.1 jar:
CREATE TABLE IF NOT EXISTS SPRING_AI_CHAT_MEMORY (
conversation_id VARCHAR(36) NOT NULL,
content TEXT NOT NULL,
type VARCHAR(10) NOT NULL CHECK (type IN ('USER', 'ASSISTANT', 'SYSTEM', 'TOOL')),
"timestamp" TIMESTAMP NOT NULL,
sequence_id BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS SPRING_AI_CHAT_MEMORY_CONVERSATION_ID_TIMESTAMP_IDX
ON SPRING_AI_CHAT_MEMORY(conversation_id, "timestamp");
CREATE INDEX IF NOT EXISTS SPRING_AI_CHAT_MEMORY_CONVERSATION_ID_SEQUENCE_ID_IDX
ON SPRING_AI_CHAT_MEMORY(conversation_id, sequence_id);
Source: Spring AI: Chat Memory.
Three details decide how you use it.
-
sequence_idis new in 2.0. Messages are ordered by it, because timestamp precision differs between databases (one second on MySQL and MariaDB by default), which made the order of messages saved in the same second non-deterministic. It is the message’s position in the conversation, assigned on everysaveAll. A table created by Spring AI 1.x needs the column added and backfilled before it works; the upgrade notes give a PostgreSQL migration that addssequence_id BIGINT, fills it withROW_NUMBER() OVER (PARTITION BY conversation_id ORDER BY "timestamp") - 1, sets itNOT NULLand creates the index. Because memory holds recent context and not permanent history, dropping and recreating the table is an accepted alternative. -
conversation_idis 36 characters, in every dialect’s script. A readableuser:labelID overflows it; the hashed UUID fromConversationIdsin Chat Memory fits exactly. A 40-character ID was rejected with aDataIntegrityViolationExceptionin a run. -
The timestamp is kept as a column and surfaced on each message as an
Instantunder the metadata keyJdbcChatMemoryRepository.CONVERSATION_TS, and it survives re-saving, so a UI can show when a message was written. The metadata also means a message read back is notequalsto an identical message you construct in code.
Supported dialects are PostgreSQL, MySQL and MariaDB, SQL Server, HSQLDB and Oracle, with scripts for H2 and SQLite in the jar too;
the dialect is detected from the JDBC URL, and you can implement JdbcChatMemoryRepositoryDialect for another database. In
production, run the schema through your migration tool with initialize-schema: never, so a Flyway or Liquibase file owns
the table together with the registry table shown in the retention section. Building the repository by hand is only
needed to share a transaction manager or dialect:
package com.example.docsassistant;
import javax.sql.DataSource;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.memory.repository.jdbc.JdbcChatMemoryRepository;
import org.springframework.ai.chat.memory.repository.jdbc.JdbcChatMemoryRepositoryDialect;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
/** Only needed when you do not want the auto-configured repository, for example to share a transaction manager. */
@Configuration(proxyBeanMethods = false)
class JdbcMemoryConfig {
@Bean
JdbcChatMemoryRepository chatMemoryRepository(DataSource dataSource, PlatformTransactionManager txManager) {
return JdbcChatMemoryRepository.builder()
.jdbcTemplate(new JdbcTemplate(dataSource))
.dialect(JdbcChatMemoryRepositoryDialect.from(dataSource)) // detected from the JDBC URL
.transactionManager(txManager)
.build();
}
@Bean
ChatMemory chatMemory(JdbcChatMemoryRepository repository) {
return MessageWindowChatMemory.builder().chatMemoryRepository(repository).maxMessages(20).build();
}
}
Source: Spring AI: Chat Memory.
In the run, a conversation that included a tool call and a tool response was stored as four rows (user, assistant, user, assistant), the framework logged a warning that tool call messages were filtered, and the messages came back oldest first with the timestamp in their metadata.
Cassandra, MongoDB, Neo4j and Redis
Each of the other stores follows the same pattern: add its starter, configure the connection the usual Spring Boot way, and the
repository bean appears; wrap it in a MessageWindowChatMemory. The class below shows the shape once for all four, with
a profile per store because only one starter is on the classpath in a real application.
package com.example.docsassistant;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.memory.repository.cassandra.CassandraChatMemoryRepository;
import org.springframework.ai.chat.memory.repository.mongo.MongoChatMemoryRepository;
import org.springframework.ai.chat.memory.repository.neo4j.Neo4jChatMemoryRepository;
import org.springframework.ai.chat.memory.repository.redis.RedisChatMemoryRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
/** Each starter auto-configures its repository bean; the window is the same code whatever the store. */
@Configuration(proxyBeanMethods = false)
class StoreBackedMemory {
@Bean
@Profile("redis")
ChatMemory redisMemory(RedisChatMemoryRepository repository) {
return window(repository);
}
@Bean
@Profile("mongo")
ChatMemory mongoMemory(MongoChatMemoryRepository repository) {
return window(repository);
}
@Bean
@Profile("neo4j")
ChatMemory neo4jMemory(Neo4jChatMemoryRepository repository) {
return window(repository);
}
@Bean
@Profile("cassandra")
ChatMemory cassandraMemory(CassandraChatMemoryRepository repository) {
return window(repository);
}
private static ChatMemory window(org.springframework.ai.chat.memory.ChatMemoryRepository repository) {
return MessageWindowChatMemory.builder().chatMemoryRepository(repository).maxMessages(20).build();
}
}
Source: Spring AI: Chat Memory.
| Store | Properties |
|---|---|
Cassandra |
|
MongoDB |
|
Neo4j |
|
Redis |
|
Some notes per store. Cassandra writes a time series, so it keeps every past chat window, which is valuable for auditing
and governance; the reference recommends a time-to-live such as three years so it does not grow forever. The reference’s
property table still shows the older prefix spring.ai.chat.memory.cassandra., while the 2.0.1 jar’s metadata uses the
repository prefix shown above, so trust the metadata in your IDE. *Neo4j stores messages as nodes and relationships and creates its own indexes
on the conversation ID and message index. Redis needs Redis Stack 7 or later (the Query Engine and RedisJSON modules), stores
messages as JSON documents with a search index, and its repository also implements AdvancedRedisChatMemoryRepository, which
adds queries by content, message type, time range and metadata across conversations. That is powerful and worth restricting: it
reads other users' messages by design. The Redis module was renamed in 2.0.1 (spring-ai-autoconfigure-model-chat-memory-repository-redis
and the repository.redis prefix, with the old names still delegating), and the usage with a vector store is on
Spring Boot: Redis as a Vector Database.
Writing your own repository
Write a repository when your store is not on the list, when you must encrypt at rest, or when you need behaviour the
built-ins lack. The contract is small, and its three traps are the ones in the list above: saveAll is a replacement, an
unsupported message type must be handled on purpose (filter it and say so, rather than store a corrupted transcript), and
findConversationIds() is a global call. The example keeps conversations in memory and lets them expire after a period
without writes, which suits a single node, a demo or a test.
package com.example.docsassistant;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.ai.chat.memory.ChatMemoryRepository;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.ToolResponseMessage;
/** A custom repository: conversations live in memory and expire after a period without writes. */
class InMemoryTtlChatMemoryRepository implements ChatMemoryRepository {
private record Entry(List<Message> messages, Instant writtenAt) { }
private final Map<String, Entry> conversations = new ConcurrentHashMap<>();
private final Duration ttl;
private final Clock clock;
InMemoryTtlChatMemoryRepository(Duration ttl, Clock clock) {
this.ttl = ttl;
this.clock = clock;
}
@Override
public List<String> findConversationIds() {
evictExpired();
return new ArrayList<>(this.conversations.keySet());
}
@Override
public List<Message> findByConversationId(String conversationId) {
evictExpired();
Entry entry = this.conversations.get(conversationId);
return entry == null ? List.of() : entry.messages();
}
@Override
public void saveAll(String conversationId, List<Message> messages) {
// the contract: this is the complete new content of the conversation, not an append
List<Message> storable = messages.stream()
.filter(m -> !(m instanceof ToolResponseMessage)
&& !(m instanceof AssistantMessage a && a.hasToolCalls())) // this store keeps text only
.toList();
this.conversations.put(conversationId, new Entry(storable, this.clock.instant()));
}
@Override
public void deleteByConversationId(String conversationId) {
this.conversations.remove(conversationId);
}
private void evictExpired() {
Instant cutoff = this.clock.instant().minus(this.ttl);
this.conversations.values().removeIf(entry -> entry.writtenAt().isBefore(cutoff));
}
}
Source: Spring AI: Chat Memory.
With a fake clock, a conversation written at time zero still had both messages five minutes later and none after eleven minutes with a ten-minute TTL,
and findConversationIds() no longer listed it. Declare it as a ChatMemoryRepository bean and the auto-configured in-memory
default backs off (its bean is conditional on there being none), and so does the default ChatMemory if you supply that too. Make
the class thread-safe, as this one is through a ConcurrentHashMap, because requests for different conversations run
concurrently.
Retention
Keeping every conversation forever is rarely justified and often a liability. Decide a retention period per purpose (context for the model needs days, not years), write it down, and enforce it in the store.
| Store | Mechanism |
|---|---|
Cassandra, MongoDB, Redis |
Native expiry: |
JDBC, Neo4j |
A scheduled purge. The job below deletes conversations idle for more than 30 days |
Custom |
Whatever your store offers, as in |
The JDBC purge works on whole conversations, judged by their newest message, so a conversation that is still in use is never half deleted.
package com.example.docsassistant;
import java.sql.Timestamp;
import java.time.Duration;
import java.time.Instant;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/** Deletes conversations that have been idle longer than the retention period (needs @EnableScheduling). */
@Component
class MemoryRetentionJob {
private static final Logger log = LoggerFactory.getLogger(MemoryRetentionJob.class);
private final JdbcClient jdbc;
private final Duration retention = Duration.ofDays(30);
MemoryRetentionJob(JdbcClient jdbc) {
this.jdbc = jdbc;
}
@Scheduled(cron = "0 15 3 * * *")
void purgeIdleConversations() {
Timestamp cutoff = Timestamp.from(Instant.now().minus(this.retention));
int rows = this.jdbc.sql("""
DELETE FROM spring_ai_chat_memory
WHERE conversation_id IN (
SELECT conversation_id FROM spring_ai_chat_memory
GROUP BY conversation_id
HAVING MAX("timestamp") < :cutoff)""")
.param("cutoff", cutoff)
.update();
this.jdbc.sql("""
DELETE FROM docs_conversation
WHERE conversation_id NOT IN (SELECT conversation_id FROM spring_ai_chat_memory)""")
.update();
log.info("retention purge removed {} message rows", rows);
}
}
Source: Spring AI: Chat Memory.
It needs @EnableScheduling and, on a multi-instance deployment, a lock so only one instance runs it (see
Scheduling & ShedLock). In the H2 run it removed the four rows of a
conversation aged 40 days, left another user’s recent conversation untouched, and cleaned the registry. Keep the number of days in
configuration and in your privacy notice, and log counts, never content.
Erasing one user’s data
A person can ask you to delete what you hold about them, or to give them a copy. Answering needs two things the memory tables do not provide by themselves: a way to find their conversations, and a deletion that reaches every copy.
A hashed conversation ID does not say whose conversation it is, and findConversationIds() lists everyone’s. So keep a
small registry from owner to conversation ID, written when a conversation is first used. Use an opaque user ID as the owner
(the token subject, not the email address), so the registry is not another store of personal data.
CREATE TABLE IF NOT EXISTS docs_conversation (
conversation_id VARCHAR(36) PRIMARY KEY,
owner VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS docs_conversation_owner_idx ON docs_conversation(owner);
Source: Spring AI: Chat Memory.
package com.example.docsassistant;
import java.util.List;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
/**
* Remembers which user owns which conversation id. The memory table only knows the id, and the id is a hash,
* so without this table "delete everything this user said" cannot be answered.
*/
@Repository
class ConversationRegistry {
private final JdbcClient jdbc;
ConversationRegistry(JdbcClient jdbc) {
this.jdbc = jdbc;
}
void register(String owner, String conversationId) {
this.jdbc.sql("""
INSERT INTO docs_conversation (conversation_id, owner)
SELECT :id, :owner
WHERE NOT EXISTS (SELECT 1 FROM docs_conversation WHERE conversation_id = :id)""")
.param("id", conversationId)
.param("owner", owner)
.update();
}
List<String> idsOwnedBy(String owner) {
return this.jdbc.sql("SELECT conversation_id FROM docs_conversation WHERE owner = :owner")
.param("owner", owner)
.query(String.class)
.list();
}
void forget(String owner) {
this.jdbc.sql("DELETE FROM docs_conversation WHERE owner = :owner").param("owner", owner).update();
}
}
Source: Spring AI: Chat Memory.
Call register(authentication.getName(), conversationId) once per request from MemoryChatService in
Chat Memory; the insert is skipped when the ID is already present. The erasure and export
service uses only the caller’s own IDs, and deletes the registry rows in the same transaction:
package com.example.docsassistant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.ai.chat.memory.ChatMemoryRepository;
import org.springframework.ai.chat.messages.Message;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
class MemoryErasureService {
private final ChatMemoryRepository repository;
private final ConversationRegistry registry;
MemoryErasureService(ChatMemoryRepository repository, ConversationRegistry registry) {
this.repository = repository;
this.registry = registry;
}
/** Right of erasure: remove every conversation the user owns, and nobody else's. */
@Transactional
int eraseAllFor(String owner) {
List<String> ids = this.registry.idsOwnedBy(owner);
ids.forEach(this.repository::deleteByConversationId);
this.registry.forget(owner);
return ids.size();
}
/** Right of access: hand the user a copy of what is stored about them. */
Map<String, List<Message>> exportFor(String owner) {
Map<String, List<Message>> export = new LinkedHashMap<>();
for (String id : this.registry.idsOwnedBy(owner)) {
export.put(id, this.repository.findByConversationId(id));
}
return export;
}
}
Source: Spring AI: Chat Memory.
package com.example.docsassistant;
import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class MemoryPrivacyController {
private final MemoryErasureService erasure;
MemoryPrivacyController(MemoryErasureService erasure) {
this.erasure = erasure;
}
// the owner is always the caller: there is deliberately no user id in the URL or the body
@DeleteMapping("/api/me/conversations")
ResponseEntity<Map<String, Integer>> eraseMine(Authentication authentication) {
return ResponseEntity.ok(Map.of("deleted", this.erasure.eraseAllFor(authentication.getName())));
}
}
Source: Spring AI: Chat Memory.
In the run, exporting listed one conversation, erasing removed it, and the other user’s conversation was still there. The endpoint does not take a user ID on purpose: the only owner it can ever act on is the authenticated caller, and an administrator who erases on someone’s behalf should use a separate, audited endpoint.
Deleting the rows is necessary and not sufficient. Check every other place the same words can live:
-
Summaries and any vector-backed memory of the same conversations (see Agent Memory & Semantic Cache);
-
logs and traces that contain prompts or completions, which is why prompt and completion logging should stay off by default (see Observability);
-
backups and replicas, which age out on their own schedule and should be documented as such;
-
the model provider, whose retention of the data you sent is governed by your agreement with it, so prefer providers and settings without training on your data, or a local model (see Local Models);
-
exports you handed out and analytics built from conversations.
This is engineering guidance, not legal advice: retention periods, lawful basis and response deadlines are for your data protection officer to set. The broader guardrails for personal data in prompts are on Security & Guardrails.