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.
| Adapter | Mechanism | Retention support |
|---|---|---|
| libSQL | prune() | All supported growth domains |
| PostgreSQL | prune() | All supported growth domains. vNext observability drops expired partitions or chunks |
| MongoDB | prune() or native TTL | All supported growth domains. Native TTL indexes are also available |
| DuckDB | prune() | Observability spans, metrics, logs, scores, and feedback |
| MySQL | prune() | Observability spans |
| Microsoft SQL Server | prune() | Observability spans |
| Oracle Database | prune() | Observability spans and logs |
| Amazon Aurora DSQL | prune() | Observability spans |
| Google Cloud Spanner | prune() | Observability spans, plus metrics when metrics storage is enabled |
| ClickHouse | Native TTL | Observability 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 maintenanceDirect link to Storage-specific maintenance
| Adapter | Maintenance guidance |
|---|---|
| SQLite and libSQL | Freed pages are reused by future writes, which stops the database file from growing. Reclaiming disk space requires database-level maintenance. |
| DuckDB | For file-backed stores, run CHECKPOINT after pruning to reclaim deleted rows in storage. DuckDB's VACUUM doesn't reclaim deleted rows. |
Schedule pruningDirect 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 exampleDirect 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.
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 configDirect link to Retention config
Set the retention field on the store config.
retention?:
[domain]?:
memory, observability). Maps that domain's retention-eligible table keys to their policies.TableRetentionPolicyDirect link to TableRetentionPolicy
maxAge:
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?:
Retention-eligible tablesDirect 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.
| Domain | Table key | Anchor column | maxAge measures |
|---|---|---|---|
memory | threads | createdAt | Thread age |
memory | messages | createdAt | Message age |
memory | resources | createdAt | Resource age |
threadState | threadState | updatedAt | Inactivity: state for still-active threads survives |
observability | spans | startedAt | Span age |
observability | metrics | timestamp | Metric event age (vNext only) |
observability | logs | timestamp | Log event age (vNext only) |
observability | scores | timestamp | Score event age (vNext only) |
observability | feedback | timestamp | Feedback event age (vNext only) |
scores | scorers | createdAt | Score record age |
workflows | workflowSnapshot | updatedAt | Inactivity, suspended or long-running workflows survive |
backgroundTasks | backgroundTasks | completedAt | Time since completion, in-flight tasks (NULL) are never pruned |
experiments | experiments | completedAt | Time since completion, running experiments are never pruned |
notifications | notifications | createdAt | Notification age |
harness | sessions | createdAt | Session record age |
schedules | triggers | actual_fire_at | Fire-history age (epoch-ms column) |
- The memory
observational_memorytable 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
resultskey. - 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
spanspolicy uses the eventtimestampcolumn rather thanstartedAt. - LibSQL and PostgreSQL support all domains above except
harness, which PostgreSQL doesn't implement. MongoDB supports all exceptthreadStateandharness. DuckDB, MySQL, Microsoft SQL Server, Oracle Database, Amazon Aurora DSQL, and Google Cloud Spanner currently support retention only in theirobservabilitydomains, 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 pastmaxAge.PruneResult.deletedreports the number of rows in the dropped partitions.
MethodsDirect link to Methods
RetentionDirect 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[]>
PruneOptionsDirect link to PruneOptions
maxBatches?:
done: false.maxRows?:
done: false.pauseMs?:
signal?:
done: false.retention?:
retention is unchanged.PruneResultDirect 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 scheduleDirect 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 TTLDirect 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.
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 MongoDBDirect 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
},
],
})
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 diskDirect 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.
Turso Cloud manages storage compaction for you, so there's nothing to reclaim manually. This applies only to self-hosted libSQL files.