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.

Table 1. The built-in repositories
Repository Starter artifact Tool messages Retention support

InMemoryChatMemoryRepository

none, in spring-ai-model; the default

stored

none, gone on restart

JdbcChatMemoryRepository

spring-ai-starter-model-chat-memory-repository-jdbc

dropped on save

none; schedule a purge

CassandraChatMemoryRepository

spring-ai-starter-model-chat-memory-repository-cassandra

dropped on save

time-to-live on writes

MongoChatMemoryRepository

spring-ai-starter-model-chat-memory-repository-mongodb

dropped on save

ttl index

Neo4jChatMemoryRepository

spring-ai-starter-model-chat-memory-repository-neo4j

stored

none; schedule a purge

RedisChatMemoryRepository

spring-ai-starter-model-chat-memory-repository-redis

stored

time-to-live on entries

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.

flowchart TD A[Must memory survive a restart?] -->|no| B[InMemoryChatMemoryRepository for development and tests] A -->|yes| C[Must the tool transcript be stored?] C -->|yes| D[Redis or Neo4j, memory advisor inside the loop] C -->|no| E[Which store do you already run?] E -->|PostgreSQL MySQL SQL Server Oracle| F[JdbcChatMemoryRepository] E -->|MongoDB| G[MongoChatMemoryRepository] E -->|Cassandra| H[CassandraChatMemoryRepository] E -->|none of these| I[JDBC on your main database, or a custom repository]
Figure 1. Choosing a repository

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>
application.yml: JDBC memory for DocsAssistant
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
Table 2. Properties under spring.ai.chat.memory.repository.jdbc
Property Meaning Default

initialize-schema

embedded, always or never. With embedded the script runs only for embedded databases (H2, HSQL, Derby), so on PostgreSQL the table is not created unless you say always

embedded

schema

Location of the script; supports classpath: URLs and a @@platform@@ placeholder

classpath:org/springframework/ai/chat/memory/repository/jdbc/schema-@@platform@@.sql

platform

Value for the placeholder

detected

continue-on-error

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:

schema-postgresql.sql
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);

Three details decide how you use it.

  • sequence_id is 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 every saveAll. 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 adds sequence_id BIGINT, fills it with ROW_NUMBER() OVER (PARTITION BY conversation_id ORDER BY "timestamp") - 1, sets it NOT NULL and creates the index. Because memory holds recent context and not permanent history, dropping and recreating the table is an accepted alternative.

  • conversation_id is 36 characters, in every dialect’s script. A readable user:label ID overflows it; the hashed UUID from ConversationIds in Chat Memory fits exactly. A 40-character ID was rejected with a DataIntegrityViolationException in a run.

  • The timestamp is kept as a column and surfaced on each message as an Instant under the metadata key JdbcChatMemoryRepository.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 not equals to 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:

JdbcMemoryConfig.java: a manually built repository
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();
    }
}

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.

StoreBackedMemory.java: the same window over each store
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();
    }
}
Table 3. Store-specific properties (2.0.1 metadata; all under spring.ai.chat.memory.repository)
Store Properties

Cassandra

cassandra.keyspace, cassandra.table, cassandra.messages-column, cassandra.time-to-live, cassandra.initialize-schema (default true). Connection: spring.cassandra.*

MongoDB

mongo.ttl (a duration, 0 means messages never expire), mongo.create-indices (default false; changing the TTL drops and recreates the TTL index). The collection ai_chat_memory is created if missing

Neo4j

neo4j.session-label, neo4j.message-label, neo4j.tool-call-label, neo4j.tool-response-label, neo4j.metadata-label, neo4j.media-label. Labels must start with a letter or underscore and contain only letters, digits and underscores, or startup fails

Redis

redis.host, redis.port, redis.username, redis.password, redis.index-name (chat-memory-idx), redis.key-prefix (chat-memory:), redis.time-to-live, redis.initialize-schema, redis.max-conversation-ids, redis.max-messages-per-conversation, and redis.metadata-fields[n].name/type for indexed metadata

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.

InMemoryTtlChatMemoryRepository.java: a custom repository with expiry
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));
    }
}

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.

Table 4. Enforcing a retention period
Store Mechanism

Cassandra, MongoDB, Redis

Native expiry: cassandra.time-to-live, mongo.ttl, redis.time-to-live. Set them; the defaults keep data indefinitely

JDBC, Neo4j

A scheduled purge. The job below deletes conversations idle for more than 30 days

Custom

Whatever your store offers, as in InMemoryTtlChatMemoryRepository

The JDBC purge works on whole conversations, judged by their newest message, so a conversation that is still in use is never half deleted.

MemoryRetentionJob.java: delete idle conversations
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);
    }
}

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.

docs_conversation: the owner registry table
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);
ConversationRegistry.java: who owns which conversation
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();
    }
}

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:

MemoryErasureService.java: erase or export one user’s conversations
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;
    }
}
MemoryPrivacyController.java: the endpoint takes no user id
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())));
    }
}

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.