Skip to main content

Storage retention

Because storage grows without bound by default, Mastra provides an opt-in, age-based retention system. Declare per-table maxAge policies in the retention config, then call storage.prune() to delete rows older than their configured age. Unconfigured data is kept forever, so behavior doesn't change until you opt in.

prune() deletes rows in bounded batches. Runs are resumable and cancellable, so you can limit how much work each maintenance window performs. Pruning doesn't reclaim disk space by itself. Use the database-specific maintenance guidance below when you need to return freed space to the operating system.

Retention covers growth tables only: tables that accumulate rows unbounded as a side effect of normal operation (conversation history, telemetry, job and run records, schedule fire history, event feeds). User-authored artifacts and config (agents, skills, workspaces, prompt blocks, datasets, schedule definitions, channel installations, and so on) grow with user intent and are edited or deleted explicitly, so they're not valid retention keys.

Storage adapters use the shared core retention contract for prune(), or a database-native mechanism when that better matches the backend.

AdapterMechanismRetention support
libSQLprune()All supported growth domains
PostgreSQLprune()All supported growth domains. vNext observability drops expired partitions or chunks
MongoDBprune() or native TTLAll supported growth domains. Native TTL indexes are also available
DuckDBprune()Observability spans, metrics, logs, scores, and feedback
MySQLprune()Observability spans
Microsoft SQL Serverprune()Observability spans
Oracle Databaseprune()Observability spans and logs
Amazon Aurora DSQLprune()Observability spans
Google Cloud Spannerprune()Observability spans, plus metrics when metrics storage is enabled
ClickHouseNative TTLObservability spans, metrics, logs, scores, and feedback. When all five signals have finite retention, deletion-request records expire after the longest signal retention plus 30 days

Storage-specific maintenance
Direct link to Storage-specific maintenance

AdapterMaintenance guidance
SQLite and libSQLFreed pages are reused by future writes, which stops the database file from growing. Reclaiming disk space requires database-level maintenance.
DuckDBFor file-backed stores, run CHECKPOINT after pruning to reclaim deleted rows in storage. DuckDB's VACUUM doesn't reclaim deleted rows.

Schedule pruning
Direct link to Schedule pruning

Run prune() from a scheduler or maintenance worker, not from application startup or shutdown hooks. For deployments that share a database, prefer a single active scheduler or worker for pruning.

Prefer lower-traffic periods when pruning large tables. Use maxBatches, maxRows, and pauseMs to bound each run, and pass an AbortSignal when the maintenance process needs to stop promptly. These recommendations apply to adapters that expose prune(). ClickHouse applies its native time to live (TTL) policy within the database.

Usage example
Direct link to Usage example

Declare retention on any MastraCompositeStore (or an adapter that extends it, such as LibSQLStore), then call prune() from your own scheduler.

src/mastra/index.ts
import { LibSQLStore } from '@mastra/libsql'

const storage = new LibSQLStore({
id: 'mastra-storage',
url: 'file:./mastra.db',
retention: {
memory: {
messages: { maxAge: '30d' },
threads: { maxAge: '90d', batchSize: 500 },
},
observability: {
spans: { maxAge: '7d' },
},
},
})

// Wire this to your own cron/scheduler: Mastra never runs it for you.
const results = await storage.prune()

retention is fully typed. Domain keys must exist, and their table keys must be declared retention-eligible. Store configs type-check objects passed directly. When building an object separately, use satisfies RetentionConfig so unknown domains or tables produce compile errors:

import type { RetentionConfig } from '@mastra/core/storage'

const retention = {
memory: {
messages: { maxAge: '30d' }, // ok
bogus: { maxAge: '30d' }, // Error: not a memory retention table
},
bogusDomain: {}, // Error: not a storage domain
} satisfies RetentionConfig

Retention config
Direct link to Retention config

Set the retention field on the store config.

retention?:

RetentionConfig
Per-domain, per-table age policies. Unset domains and tables are kept forever.
RetentionConfig

[domain]?:

Record<TableKey, TableRetentionPolicy>
A real storage domain key (e.g. memory, observability). Maps that domain's retention-eligible table keys to their policies.

TableRetentionPolicy
Direct link to TableRetentionPolicy

maxAge:

Duration
Maximum age to keep rows. Rows whose anchor timestamp is strictly older than Date.now() - maxAge are eligible for deletion. A number is milliseconds, or a string with a unit suffix: ms, s, m, h, d, w (e.g. '30d', '12h').

batchSize?:

number
= 1000
Rows deleted per batch. Each batch is its own transaction, which bounds lock duration and WAL growth on large tables.

Retention-eligible tables
Direct link to Retention-eligible tables

Each domain specifies its age-prunable tables and the timestamp column that anchors comparison, chosen so maxAge matches the meaning of the data. Append-only logs use creation time, live state uses last activity, and jobs or runs use completion time so in-flight work isn't pruned.

DomainTable keyAnchor columnmaxAge measures
memorythreadscreatedAtThread age
memorymessagescreatedAtMessage age
memoryresourcescreatedAtResource age
threadStatethreadStateupdatedAtInactivity: state for still-active threads survives
observabilityspansstartedAtSpan age
observabilitymetricstimestampMetric event age (vNext only)
observabilitylogstimestampLog event age (vNext only)
observabilityscorestimestampScore event age (vNext only)
observabilityfeedbacktimestampFeedback event age (vNext only)
scoresscorerscreatedAtScore record age
workflowsworkflowSnapshotupdatedAtInactivity, suspended or long-running workflows survive
backgroundTasksbackgroundTaskscompletedAtTime since completion, in-flight tasks (NULL) are never pruned
experimentsexperimentscompletedAtTime since completion, running experiments are never pruned
notificationsnotificationscreatedAtNotification age
harnesssessionscreatedAtSession record age
schedulestriggersactual_fire_atFire-history age (epoch-ms column)
note
  • The memory observational_memory table has no timestamp anchor, so it can't be age-pruned and isn't a valid retention key.
  • Experiments prune as whole units: an aged experiment's result rows are deleted together with it (results cascade with their parent), so a run is never left partially deleted. Retention doesn't have a separate results key.
  • For schedules, the growth table is the fire history (schedule_triggers, one row per fire): schedule definitions are config and aren't pruned.
  • On PostgreSQL, timestamp anchors use the timezone-aware mirror columns (for example createdAtZ, completedAtZ).
  • DuckDB observability stores append-only events for all five signals. Its spans policy uses the event timestamp column rather than startedAt.
  • LibSQL and PostgreSQL support all domains above except harness, which PostgreSQL doesn't implement. MongoDB supports all except threadState and harness. DuckDB, MySQL, Microsoft SQL Server, Oracle Database, Amazon Aurora DSQL, and Google Cloud Spanner currently support retention only in their observability domains, with the signal coverage shown in the support matrix.
  • The vNext PostgreSQL observability domain stores signal events in day-partitioned tables (spans, metrics, logs, scores, feedback). For it, prune() drops whole day partitions (or TimescaleDB chunks) that are entirely older than the cutoff instead of deleting rows: effective level of detail is one day, and a partition is only dropped once its entire day is past maxAge. PruneResult.deleted reports the number of rows in the dropped partitions.

Methods
Direct link to Methods

Retention
Direct link to Retention

prune(options?)
Direct link to pruneoptions

Deletes rows older than their configured maxAge across every domain that has a policy in retention. Returns one PruneResult per table touched. With no retention configured it's a no-op returning [].

prune() is designed to be safe on tables with millions of rows. It deletes in bounded, batched chunks (each batch is its own transaction) so it never takes a long lock or bloats the transaction log. It never runs a VACUUM.

Pass options.retention to replace the configured policies for that call only: for example to skip a domain (keep chat history) or prune more aggressively than the standing config. The store's configured retention is unchanged.

Adapters that use anchor-column indexes create them lazily on the first prune() call for each table with a policy (never at init()) so deployments that don't configure retention pay no extra index write or disk overhead. The first prune of an existing large table pays a one-time index build. Subsequent prunes reuse the index. DuckDB uses its built-in zone maps instead of creating retention indexes.

const results = await storage.prune({
maxRows: 50_000, // cap work this call
pauseMs: 50, // breathe between batches
})

for (const r of results) {
console.log(`${r.domain}.${r.table}: deleted ${r.deleted}, done=${r.done}`)
}

// One-off pass with different policies (configured retention untouched):
await storage.prune({
retention: {
observability: { spans: { maxAge: '1d' } },
},
})

Returns: Promise<PruneResult[]>

PruneOptions
Direct link to PruneOptions

maxBatches?:

number
Maximum delete batches per table per call. When reached, that table's result is returned with done: false.

maxRows?:

number
Maximum rows deleted per table per call. When reached, that table's result is returned with done: false.

pauseMs?:

number
Delay in milliseconds between batches, to avoid starving live traffic.

signal?:

AbortSignal
Cooperative cancellation. The batch loop checks it between batches and stops cleanly, returning partial results with done: false.

retention?:

RetentionConfig
Replaces the store's configured retention policies for this call only — e.g. to skip a domain or prune more aggressively. The configured retention is unchanged.
PruneResult
Direct link to PruneResult

Each result describes one table's progress:

interface PruneResult {
domain: string // e.g. 'memory'
table: string // physical table name, e.g. 'mastra_messages'
deleted: number // rows deleted during this call
done: boolean // false => eligible rows remain; call prune() again
}

Running prune on a schedule
Direct link to Running prune on a schedule

prune() has no built-in scheduler, so you decide when it runs. A bounded call may leave eligible rows, indicated by any result with done: false. Call it again on the next tick. Short invocations let a large backlog drain over several runs.

// Runs on your own cron (node-cron, a workflow schedule, an external job, etc.).
async function retentionTick() {
const results = await storage.prune({ maxRows: 100_000, pauseMs: 25 })
const incomplete = results.filter(r => !r.done)
if (incomplete.length) {
// Rows remain; the next scheduled tick will continue where this one stopped.
console.log(
'retention still draining:',
incomplete.map(r => `${r.domain}.${r.table}`),
)
}
}

You can also cancel a long-running prune with an AbortSignal: the loop stops between batches and returns partial results with done: false, so the next run resumes cleanly.

ClickHouse native TTL
Direct link to ClickHouse native TTL

ClickHouse observability storage uses native table TTLs instead of prune(). Configure retention as days per signal. init() applies the TTLs to new and existing tables and skips ALTER TABLE statements when the configured TTL is already present.

Omitted signals, and signals set to zero or less, get no TTL. When you remove a signal from retention, the next init() or applyRetention() removes that table's TTL. If Mastra can't read the current TTLs from system.tables, it applies the configured TTLs and leaves the others unchanged.

For deployments that need to update TTL configuration without running the full initialization path, call applyRetention() on the vNext observability store:

import { ObservabilityStorageClickhouseVNext } from '@mastra/clickhouse'

const observability = new ObservabilityStorageClickhouseVNext({
client,
retention: {
tracing: 30,
logs: 7,
metrics: 14,
scores: 90,
feedback: 60,
},
})

await observability.applyRetention()

Deletion requests are retained long enough to keep enforcing erasure after signal rows expire. Mastra applies a TTL to mastra_deletion_requests only when tracing, logs, metrics, scores, and feedback all have finite retention. The deletion-request TTL is the longest of those periods plus 30 days. For example, if score retention is the longest period at 90 days, deletion requests expire after 120 days. When any signal is unbounded, deletion requests remain unbounded because trace deletion requests cover rows across all five signals.

MongoDB TTL indexes (alternative to prune)
Direct link to MongoDB TTL indexes (alternative to prune)

MongoDB offers native TTL (Time-To-Live) indexes that automatically delete expired documents without requiring manual prune() calls. This is a database-level feature that runs as a background thread.

When to use TTL vs prune()

Use MongoDB TTL indexes when:

  • You want automated, zero-maintenance deletion
  • Your retention periods are fixed (e.g., "always 30 days")
  • You prefer database-native solutions

Use prune() when:

  • You need fine-grained control over deletion timing
  • You want to cap deletion rate during business hours
  • You need resumable, cancellable cleanup operations
  • You're using composite storage with multiple databases

Both approaches are valid. TTL is simpler. prune() gives more control.

Setting up TTL indexes on MongoDB
Direct link to Setting up TTL indexes on MongoDB

TTL indexes work on date fields. MongoDB checks the index every 60 seconds and deletes documents where the date field + TTL duration < current time.

import { MongoDBStore } from '@mastra/mongodb'

const storage = new MongoDBStore({
id: 'mongodb-storage',
uri: process.env.MONGODB_URI!,
dbName: process.env.MONGODB_DB_NAME!,
indexes: [
// Messages expire after 30 days
{
collection: 'mastra_messages',
keys: { createdAt: 1 },
options: { expireAfterSeconds: 30 * 24 * 60 * 60 }, // 30 days
},
// Threads expire after 90 days
{
collection: 'mastra_threads',
keys: { createdAt: 1 },
options: { expireAfterSeconds: 90 * 24 * 60 * 60 }, // 90 days
},
// Spans expire after 7 days
{
collection: 'mastra_ai_spans',
keys: { startedAt: 1 },
options: { expireAfterSeconds: 7 * 24 * 60 * 60 }, // 7 days
},
],
})
tip

TTL indexes delete documents shortly after they expire (background thread runs every ~60 seconds), but the exact timing isn't guaranteed. For precise, immediate cleanup, use prune() instead.

Reclaiming disk
Direct link to Reclaiming disk

prune() deletes rows but doesn't shrink the database file. On SQLite/libSQL the freed pages go on a freelist and are reused by future writes, so the file stops growing: for most users this alone solves the unbounded-growth problem.

Handing that free space back to the OS is a separate concern that Mastra doesn't manage. If you specifically need to shrink the file, run the underlying database's compaction (for example VACUUM on self-hosted libSQL) yourself in a maintenance window. A full VACUUM locks the file and needs roughly twice the file size in free disk. On PostgreSQL, autovacuum reclaims dead tuples for reuse automatically. A manual VACUUM FULL is only needed if you must return disk to the OS.

For MongoDB, deleted documents are reused by future insertions. To reclaim disk space, run db.runCommand({ compact: "collection_name" }) during a maintenance window.

libSQL and Turso

Turso Cloud manages storage compaction for you, so there's nothing to reclaim manually. This applies only to self-hosted libSQL files.