How are you handling long-term memory for your agents? Mem0, Zep, or rolling your own?
TL;DR
- A memory you run yourself on one SQLite table with full-text search fits in 38 lines. The code is below, whole.
- In the test run, six checks behaved as predicted: recall, replace an outdated fact, keep one user's queries away from another's, forget, FTS5 present, and one expected miss on a paraphrase.
- It stops being enough at places you can measure: a paraphrase finds nothing, word forms are not matched, an old fact can stay hidden behind a deleted replacement, user separation lives only in your queries, and a written memory can carry instructions forward.
- A changed fact is handled differently by Mem0 and Zep, in their own documentation. What rules your agent needs stays your decision.
The first answer to the question: a table or files you can read and fix by hand is a reasonable start, as long as you decide what a correction, a deletion and a user boundary mean before you write the code. The recipe makes each of those decisions visible. Mem0 and Zep take some of them at write time and leave others to your application, as the sections below show in their own words.
| What happens when... | Your own SQLite recipe (measured) | Mem0 (its own words) | Zep (its own words) |
|---|---|---|---|
| A fact changes | replaces= keeps the old row and recall skips it. | Open Source: "When information changes, the new fact is stored alongside the old one." Platform: "Dream marks the older memory as superseded and links it to the newer fact that replaced it." | "When new data invalidates a prior fact, the time the fact became invalid is stored on that fact’s edge in the Context Graph." |
| Something is deleted | forget removes the row and its index entry. Deleting a replacement leaves the old row hidden. | "Mem0 lets you delete a specific memory, a list of IDs, or everything that matches a filter." | "If an episode invalidates a fact, and the episode is deleted, the fact will remain marked as invalidated." |
| Users must stay apart | Only the WHERE user_id = ? conditions do it. The index holds every user's text. | Platform: "By tagging each write and query with the right identifiers, you can prevent data from mixing between them, maintain clear audit trails, and control data retention." | "Your application owns the security boundary around Zep." |
| Text is written without a check | remember stores whatever text the caller passes. | "Mem0 sends the messages through an LLM that pulls out key facts, decisions, or preferences to remember." | "Memory is evidence for a decision. Memory is not an instruction, policy, credential, permission, or authorization to act." |
| The question uses other words | The paraphrase test returned nothing. | Its How it works page says each memory "is embedded for semantic search" and lists a Semantic search signal. That describes the mechanism; this page ran no paraphrase test on Mem0. | Its graph search docs say it "combines semantic similarity with BM25 full-text search". Documented mechanism, not tested here. |
Published by Mnemoverse, a persistent-memory API for AI agents.
A superseded fact is an older memory that something newer has replaced, which a store may keep but flag instead of deleting. In the recipe the flag is a column that recall filters on. Mem0's Platform docs say its superseded memories are "not hidden by default", so the term does not imply hiding.
A full-text index is a search structure that matches the words of a query against the words of stored text. The recipe uses SQLite's FTS5 module, which SQLite describes as "an SQLite virtual table module that provides full-text search functionality to database applications."
What are the ways to give an agent long-term memory?
You can own the storage and write the rules yourself: files or a table, plus code that chooses what to keep and what to retrieve. The recipe below takes the table route.
You can run a memory SDK or graph framework. Mem0 describes its open-source option as a "Local SDK that you run in your own environment." (Mem0 add) Zep's documentation describes Graphiti as "the open-source temporal knowledge graph framework." (Graphiti overview)
You can also use a hosted service. Mem0 describes its Platform as a "Fully managed API with dashboard and scaling." (Mem0 add) Zep's documentation says: "Graphiti provides the graph framework. Zep operates Context Graphs as a managed, governed service." (Zep versus Graphiti)
Hosting is a second axis, not a quality score. Decide who operates the service, then separately decide what should happen when a remembered fact changes. The rest of this page is about the second question.
What does a minimal memory you run yourself look like?
This is the complete 38-line memory_recipe.py.
import sqlite3, time
SCHEMA = """
CREATE TABLE IF NOT EXISTS memories (id INTEGER PRIMARY KEY, user_id TEXT NOT NULL,
text TEXT NOT NULL, created_at REAL NOT NULL, superseded_by INTEGER);
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(text, content='memories', content_rowid='id');
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
INSERT INTO memories_fts(rowid, text) VALUES (new.id, new.text); END;
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, text) VALUES ('delete', old.id, old.text); END;
"""
def connect(path="memory.db"):
db = sqlite3.connect(path)
db.executescript(SCHEMA)
return db
def remember(db, user_id, text, replaces=None):
with db:
new_id = db.execute("INSERT INTO memories (user_id, text, created_at) VALUES (?, ?, ?)",
(user_id, text, time.time())).lastrowid
if replaces is not None:
db.execute("UPDATE memories SET superseded_by = ? WHERE id = ? AND user_id = ?",
(new_id, replaces, user_id))
return new_id
def recall(db, user_id, query, k=5):
terms = ['"' + w.replace('"', '""') + '"' for w in query.split()]
if not terms:
return []
return db.execute(
"SELECT m.id, m.text FROM memories_fts JOIN memories m ON m.id = memories_fts.rowid "
"WHERE memories_fts MATCH ? AND m.user_id = ? AND m.superseded_by IS NULL "
"ORDER BY bm25(memories_fts) LIMIT ?", (" OR ".join(terms), user_id, k)).fetchall()
def forget(db, user_id, row_id):
with db:
db.execute("DELETE FROM memories WHERE id = ? AND user_id = ?", (row_id, user_id))What it does:
- It keeps a search index in step with the table. Two triggers update an external-content index on every insert and delete made through this code. SQLite leaves that consistency to the application: "it is the responsibility of the user to ensure that the contents of the full-text index are consistent with the named database object." (FTS5)
- It quotes every query word. Each word is wrapped in double quotes and the words are joined with
OR, so query syntax typed by a user stays inside quoted terms.ORDER BY bm25()ascending puts the strongest match first, because "Better matches are assigned numerically lower values." (FTS5) - It never deletes on replacement.
replaces=setssuperseded_byon the older row. Recall skips that row, and the row stays in the table. - It filters by user in each query. Recall, replacement and deletion all carry a
user_idcondition. That condition is part of these queries and nothing else.
Does it work? Six checks and what they found
To reproduce the run, save the recipe as memory_recipe.py and run this next to it.
import os, sqlite3, sys
import memory_recipe as m
if os.path.exists("check.db"):
os.remove("check.db")
db = m.connect("check.db")
print("python", sys.version.split()[0], "| sqlite", sqlite3.sqlite_version)
def show(name, ok, rows):
print("PASS" if ok else "FAIL", "|", name, "|", rows)
dep = m.remember(db, "alice", "Deploys go through the staging pipeline on Fridays")
r = m.recall(db, "alice", "staging pipeline")
show("1 recall finds a stored fact", len(r) == 1, r)
old = m.remember(db, "alice", "The API rate limit is 100 requests per minute")
new = m.remember(db, "alice", "The API rate limit is 500 requests per minute", replaces=old)
r = m.recall(db, "alice", "API rate limit")
show("2 replaced fact returns only the new value", [i for i, _ in r] == [new], r)
r = m.recall(db, "bob", "staging pipeline")
show("3 another user sees nothing", r == [], r)
m.forget(db, "bob", dep)
kept = len(m.recall(db, "alice", "staging pipeline")) == 1
m.forget(db, "alice", dep)
r = m.recall(db, "alice", "staging pipeline")
show("4 only the owner can forget", kept and r == [], r)
probe = sqlite3.connect(":memory:")
try:
probe.execute("CREATE VIRTUAL TABLE t USING fts5(x)")
fts5, note = True, "created; ENABLE_FTS5 in compile_options = %s" % any(
o[0] == "ENABLE_FTS5" for o in probe.execute("PRAGMA compile_options"))
except sqlite3.OperationalError as e:
fts5, note = False, str(e)
show("5 FTS5 exists in this build", fts5, note)
m.remember(db, "alice", "The user prefers dark mode in the editor")
r = m.recall(db, "alice", "which colour scheme do they like")
show("6 paraphrase with no shared words (expected miss)", r == [], r)
m.remember(db, "alice", "Backups run nightly at 02:00")
print("INFO backup ->", m.recall(db, "alice", "backup"))
print("INFO backups ->", m.recall(db, "alice", "backups"))
print("INFO the editor mode ->", m.recall(db, "alice", "the editor mode"))
m.forget(db, "alice", new)
print("INFO after forgetting the replacement ->", m.recall(db, "alice", "API rate limit"),
"| old row superseded_by =", db.execute("SELECT superseded_by FROM memories WHERE id = ?", (old,)).fetchone()[0])
last = m.remember(db, "alice", "Unrelated note about the build")
print("INFO new row id =", last, "| the deleted replacement had id", new)
a = m.remember(db, "alice", "Temporary note")
m.forget(db, "alice", a)
b = m.remember(db, "alice", "Different note")
print("INFO id of the deleted newest row =", a, "| id of the next insert =", b)Output of that script on 2026-11-13, Python 3.14.3, SQLite 3.50.4, Windows 11:
python 3.14.3 | sqlite 3.50.4
PASS | 1 recall finds a stored fact | [(1, 'Deploys go through the staging pipeline on Fridays')]
PASS | 2 replaced fact returns only the new value | [(3, 'The API rate limit is 500 requests per minute')]
PASS | 3 another user sees nothing | []
PASS | 4 only the owner can forget | []
PASS | 5 FTS5 exists in this build | created; ENABLE_FTS5 in compile_options = True
PASS | 6 paraphrase with no shared words (expected miss) | []
INFO backup -> []
INFO backups -> [(5, 'Backups run nightly at 02:00')]
INFO the editor mode -> [(4, 'The user prefers dark mode in the editor'), (3, 'The API rate limit is 500 requests per minute')]
INFO after forgetting the replacement -> [] | old row superseded_by = 3
INFO new row id = 6 | the deleted replacement had id 3
INFO id of the deleted newest row = 7 | id of the next insert = 7Six of six checks behaved as predicted, and the sixth is a predicted miss, not a pass for retrieval. The question "which colour scheme do they like" shares no word with "The user prefers dark mode in the editor", so nothing came back.
Two cautions come with the green lines.
- FTS5 depends on how SQLite was built. SQLite documents it as enabled by a configure option or a compile-time symbol, and as "disabled by default for the source-tree configure script and enabled by default for the amalgamation configure script, but these defaults might change in the future." It was available in the Python build tested here. Check yours with
CREATE VIRTUAL TABLE ... USING fts5. - User separation exists only in the
WHEREclauses. The index table holds every user's text, so any other code path that readsmemories_ftsdirectly sees all of it. See Can a user retrieve information outside their permissions, including through the lexical search path before you rely on check 3 as an access control.
Where does your own memory stop being enough?
Each limit below was measured in the run above, or comes from a vendor's own page. Each one is also a test you can write for your own agent.
A replacement needs a deletion rule. Forgetting the new rate-limit row left recall empty for "API rate limit". The old row still carried a pointer to row 3, a row that no longer exists. The old memory was hidden behind a pointer to nothing. Several policies are possible: keep the older fact marked invalid and return no current fact, keep a tombstone row, reactivate the older row under an explicit rule, or refuse to delete a row that replaced another. The recipe has none of them, so choose one on purpose. The last INFO line adds a related edge: after the newest row was deleted, the next insert received the same id, because INTEGER PRIMARY KEY without AUTOINCREMENT reused it. A dangling pointer can then refer to an unrelated new row. For the wider question of what to do with corrections, see writing corrections as replacements and Stale Memory Is Worse Than No Memory.
Word matching needs a retrieval rule. backup did not find "Backups", while backups did. SQLite documents the default tokenizer as "The unicode61 tokenizer, based on the Unicode 6.1 standard. This is the default." It also documents "The porter tokenizer, which implements the porter stemming algorithm", and the recipe does not use it. Stemming would fix word forms. It would not connect "colour scheme" to "dark mode": SQLite notes that "Custom tokenizers may also support synonyms", and meaning-level matching needs synonyms or another retrieval method altogether. FTS5 matches words, not meaning.
Common words make weak matches. the editor mode returned the editor preference first and the unrelated rate-limit row second, because the word "the" matched. bm25 ranked them. It did not drop the weak hit, and the OR query accepts one shared word.
A user filter needs an access rule. The check exercised these functions, not every possible reader of the database. Direct access to the index, a second query path or a cache can all bypass a WHERE condition that lives in one function.
A write needs a trust rule. The recipe stores whatever text the caller passes, and a stored line comes back in later runs. Zep's security documentation names the same risk in its own words:
Persistent agent memory lets content from one interaction affect later interactions. An attacker can use this behavior to store false facts or instructions that influence a later response or tool call.
The same page says: "Memory is evidence for a decision. Memory is not an instruction, policy, credential, permission, or authorization to act." (Zep memory security) Ask whether your agent treats retrieved text as evidence or obeys it as an instruction. Shared Memory Poisoning and Can Retrieval Tell Which Source to Trust? cover the two halves of that question.
Not tested. Two writers at once, size beyond a handful of rows, and durability after a crash were not tested. This is a starting recipe. Treat it as a prototype until those three are tested.
What does Mem0 do when a fact changes?
The sections on Mem0 and Zep use only each vendor's own words, read on the date in Sources. The docs change, so check them before you rely on a detail.
Mem0's add documentation says: "Mem0 sends the messages through an LLM that pulls out key facts, decisions, or preferences to remember." and "New memories are added without overwriting or deleting existing memories." Its explanation of how it works adds: "Use explicit update or delete operations when your application needs to correct or remove a memory." (Add, How it works)
For Open Source, Mem0's migration page says: "The ADD-only model means memories accumulate over time. When information changes, the new fact is stored alongside the old one." (Open Source migration) The Platform migration page describes the v3 automatic add as "ADD only: nothing is overwritten or deleted". Before v3 the memory mutations were ADD, UPDATE and DELETE, and v3 describes its add as "Single-pass ADD-only (one LLM call)". (Platform migration)
Platform adds a separate behaviour. Mem0's Dream page says: "Supersede runs automatically as part of adding memories, on every plan." It also says: "Dream marks the older memory as superseded and links it to the newer fact that replaced it." and "Superseded memories are not deleted and not hidden by default." To ask only for the current fact, "When you want only the current truth, ask for it explicitly with latest_only=true". (Dream) Mem0's pricing page, read on 2026-11-13, lists only Free and Custom plans and puts Dream under Custom, not Free, so the two pages disagree on plan availability. (Pricing) Open Source is a different matter. Mem0's comparison table lists Dream as "Not available" in the Open Source column, and the same page says "Both run the same core extraction and retrieval logic; the Platform adds hosting, a small set of v3-only capabilities, and management surfaces that OSS does not have." (Platform versus Open Source)
An explicit update is different from supersession. Mem0 says: "Mem0’s update operation lets you fix or enrich an existing memory without deleting it." Then it says: "Mem0 overwrites the stored value and adjusts indexes." (Update)
For deletion, Mem0 says it "lets you delete a specific memory, a list of IDs, or everything that matches a filter", and describes the operation as one that "Satisfies user erasure (GDPR/CCPA) without touching the rest of your data." (Delete) That second sentence is Mem0's own description, quoted, and this page draws no compliance conclusion from it.
Scoping depends on what you pass. Mem0 describes tagging as the way to keep memories apart: "By tagging each write and query with the right identifiers, you can prevent data from mixing between them, maintain clear audit trails, and control data retention." (Entity-scoped memory) The same page says: "Passing both user_id and agent_id to client.add does not produce records with both fields set." And Mem0 documents an edge in deletion filters: "An empty or whitespace-only ID such as user_id="" matches every memory in the project, so the SDKs raise an error before sending the request." (Delete) All three passages describe Mem0's Platform: the first two sit on a page about "Mem0’s Platform API", the third under the heading "Delete memories by filter (Platform)". This page did not check how Open Source handles these cases.
What does Zep do when a fact changes?
Zep describes itself as "the unified context layer for enterprise data" that "organizes this context in temporal Context Graphs". (Overview) It describes facts as "time-stamped claims stored on edges that capture relationships about specific events." On new data, its facts page says: "When incorporating new data, Zep looks for existing nodes and edges in the graph and decides whether to add new nodes/edges or to update existing ones." An update could mean "updating an edge (for example, indicating the previous fact is no longer valid)". (Facts) Its concepts page says: "When new data invalidates a prior fact, the time the fact became invalid is stored on that fact’s edge in the Context Graph." (Concepts)
Read the timing fields as inferences. Zep's table says valid_at and invalid_at hold the time "When Zep infers the fact became valid" and "When Zep infers the fact stopped being valid", and that expired_at holds the time "When Zep marks the fact as invalidated". Zep adds: "The fields do not establish that the source content or the derived fact is true." (Facts) Zep infers the times, and Zep says the fields do not establish truth. For the general idea of two dates per fact, see Bitemporal Memory for AI Agents.
The scope is the user, not a single conversation. Zep calls a conversation a thread: "Threads represent a conversation. Each User can have multiple threads, and each thread is a sequence of chat messages." (Threads) The graph merges them: "The knowledge graph does not separate the data from different threads, but integrates the data together to create a unified picture of the user." (Users and user graphs) And the boundary is yours: "Your application owns the security boundary around Zep." (Memory security)
Self-hosting is a place where one sentence read alone misleads, so here are Zep's own words together. Its FAQ says: "Zep Community Edition, which allows you to host Zep locally, is deprecated and no longer supported." The same page lists "Graphiti" as "The open-source Context Graph framework that powers Zep Cloud". (FAQ) The Graphiti overview says "Use Zep when you need managed ingestion, retrieval, governance, and deployment options such as bring your own cloud (BYOC)." (Graphiti overview) The Graphiti README says: "You supply the graph database, LLM, and embedder, and you build the service around it: ingestion, tenancy, access control, and operations." (README) And Zep's comparison page says to choose Graphiti "if you want a flexible OSS core and you are comfortable building and operating the surrounding system." (Zep versus Graphiti)
What Zep says a delete leaves behind
Zep documents that "A delete is asynchronous: the call returns a task, and Zep removes the data in the background." It also says: "If an episode invalidates a fact, and the episode is deleted, the fact will remain marked as invalidated." and "This episode information may still exist within these nodes." (Deleting data from the graph) For users, the same page says "Deleting a user deletes the threads of the user, their messages, and the user graph."
The retention page is explicit about residue: "A deletion is permanent. Zep has no undelete operation." It also says "When you delete an episode or a thread, Zep keeps a derived node or edge that other episodes also support." and "Some internal copies keep the deleted data after the deletion. No API, SDK, or dashboard operation reads these copies." And: "Zep cannot remove one customer’s data from a backup. The data stays until the backup expires." (Context retention)
Zep's pages describe what a delete leaves behind; the equivalent was not checked for Mem0, and this page says nothing either way. For a wider look at what survives a delete, see Agent Memory Deletion: What Survives a Delete.
How do you decide: your own, Mem0, Zep, or another service?
Turn the recipe's limits into acceptance tests, and run the same tests against any option.
- After a correction, should recall return the new fact alone, or keep both reachable?
- If the replacement is deleted, should the older fact come back, stay invalid, or block the delete?
- Must a question in different words retrieve the same memory? If yes, stemming is not enough.
- If user separation matters beyond your
WHEREclauses, what enforces it outside your query code? - What stops a stored line from being treated as an instruction?
- Can two writers run at once, and does a crash lose a write? The recipe does not answer either.
If your facts rarely change and the notes are still short enough to read by eye, your own memory is a workable answer, and the recipe above is a starting point. If facts change, several users share a store or you need deletion you can describe precisely, either build the rules above or choose a service that documents them, and test it against these same questions. For Mem0, tell apart Open Source extraction, Platform Dream and an explicit update. For Zep, tell apart inferred validity times, user-graph scope and what a delete leaves behind.
Hosted services in this space include Mem0 Platform, Zep Cloud and Mnemoverse, whose docs say: "Same Mnemoverse account, same memories, in every connected tool." (Mnemoverse MCP server) All three offer a free tier: Mem0 says "Sign up and test the Platform with our free tier. No credit card required." (Mem0), Zep's FAQ answers "Yes - Zep offers a free tier." (Zep), and Mnemoverse's page says "free tier, no credit card" (Mnemoverse).
For a broader survey, see Best Persistent Memory APIs for AI Agents (2026) and Mem0 vs Zep vs Letta vs Cognee vs Supermemory.
What this page does not claim
No performance or benchmark figure is given for anyone. The recipe was run once on the versions named above, and your SQLite build may differ. Mem0 and Zep are described in their own words on the date in Sources, and their docs may have changed since. The paths named are examples, not a list of every option.
Common questions
How are you handling long-term memory for your agents? Mem0, Zep, or rolling your own?
Decide first what a correction, a deletion and a user boundary must mean for your agent, then pick the option that implements those rules. A 38-line SQLite recipe makes the rules easy to inspect, and its measured limits are a paraphrase miss, no stemming by default, and an old fact left hidden after its replacement is deleted. Mem0 and Zep document different behaviour for a changed fact. This page does not rank them.
Has anyone found Mem0 or Zep better at not losing things on update?
This page has no comparative test, so it only reports what each vendor documents. Mem0 says an explicit update "overwrites the stored value and adjusts indexes", that automatic adds are "added without overwriting or deleting existing memories", and that on Platform "Superseded memories are not deleted and not hidden by default." Zep says that when new data invalidates a prior fact, "the time the fact became invalid is stored on that fact's edge in the Context Graph."
How does something get marked as a decision when it happens?
Your writing code decides, at write time. The recipe in this article has no decision label, and its replaces= argument records a replacement, not a decision. Mem0 says its extraction "pulls out key facts, decisions, or preferences to remember". Zep says it "decides whether to add new nodes/edges or to update existing ones", which is a graph operation, not a decision label for your application.
Does the memory need to stay private to each user, or be shared across agents?
Choose that rule explicitly, because each option puts the boundary in a different place. In the recipe the boundary is the user_id condition in its queries, while its search index holds every user's text. Mem0 documents scoping by identifiers and notes two edge cases: a record written with two identifiers does not carry both, and an empty identifier matches every memory. Zep documents a graph per user and says "Your application owns the security boundary around Zep."
Can SQLite full-text search find a memory when the question uses different words?
Not by meaning. FTS5 matches words, and a paraphrase with no shared words returned nothing in the recipe's test. A stemming tokenizer fixes word forms such as backup and backups. It does not connect "colour scheme" to "dark mode"; that takes synonyms or another retrieval method.
Sources
Sources were read on 2026-11-13; the page describes them as of that date.
Recipe and SQLite
- The recipe and the check script above, run 2026-11-13 on Python 3.14.3 and SQLite 3.50.4.
- SQLite FTS5 Extension: external-content tables, ranking, tokenizers, build options.
Mem0 documentation (version 3)
- Memory operations: add, update, delete.
- How it works and Platform versus Open Source.
- Entity-scoped memory and Dream.
- Migration: Open Source and Platform.
- Overviews: Open Source and Platform.
- Pricing on mem0.ai, read 2026-11-13: the plans it lists and where it puts Dream.
Zep and Graphiti documentation (version 4)
- Overview, concepts, facts, graph overview, searching the graph.
- Users and user graphs and threads.
- Deleting data from the graph, context retention, memory security.
- Graphiti overview, FAQ, Zep versus Graphiti, Graphiti README.
Mnemoverse documentation
Related
- Best Persistent Memory APIs for AI Agents (2026): Six Questions, Six Systems
- Mem0 vs Zep vs Letta vs Cognee vs Supermemory, Q3 2026
- Can a user retrieve information outside their permissions, including through the lexical search path?
- How can I let an AI assistant learn from user corrections while keeping context lean?
- Stale Memory Is Worse Than No Memory
- Agent Memory Deletion: What Survives a Delete
- Shared Memory Poisoning: One Bad Write, Many Agents
- Can Retrieval Tell Which Source to Trust?
- Bitemporal Memory for AI Agents: The Missing Axis
Edward Izgorodin · Mnemoverse · 2026-11-13
